Initial commit: shiro-neko 0.1.0-beta.1
Agentic coding CLI on Bun, Ink, and the AI SDK. Core: streamText loop with SDK-level tool approval so a denied call provably never executes; endpoint fallback for OpenAI reasoning models; retry with backoff. Tools: read/write/edit/glob/grep/bash, path-jailed, gitignore-aware, ripgrep with a JS fallback, binary rejection, live bash streaming. Agents: five variants crossing thinking level with tool restriction; plan and review withhold mutating tools from the model. Extensibility: frontmatter skills with on-demand bodies, plugin host with blocking hooks, MCP stdio and HTTP, read-only subagents. State: durable per-project memory, session task lists, session persistence, compaction that repairs provider-item dependencies. Distribution: five-platform cross-compiled binaries with checksums, install scripts, CI on three operating systems. 404 tests, typecheck clean.
This commit is contained in:
+106
@@ -0,0 +1,106 @@
|
||||
export type ThinkingLevel = 'off' | 'low' | 'medium' | 'high' | 'max';
|
||||
|
||||
/** Maps our vocabulary to the SDK's, which each provider then maps to its own knob. */
|
||||
const SDK_REASONING: Record<ThinkingLevel, 'none' | 'low' | 'medium' | 'high' | 'xhigh'> = {
|
||||
off: 'none',
|
||||
low: 'low',
|
||||
medium: 'medium',
|
||||
high: 'high',
|
||||
max: 'xhigh',
|
||||
};
|
||||
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = ['off', 'low', 'medium', 'high', 'max'];
|
||||
|
||||
export const isThinkingLevel = (v: string): v is ThinkingLevel => (THINKING_LEVELS as string[]).includes(v);
|
||||
|
||||
export const sdkReasoning = (level: ThinkingLevel) => SDK_REASONING[level];
|
||||
|
||||
export type AgentVariant = {
|
||||
name: string;
|
||||
summary: string;
|
||||
thinking: ThinkingLevel;
|
||||
/** Appended to the system prompt to shape behaviour. */
|
||||
appendix: string;
|
||||
/** When set, only these tools are offered. Omit to offer everything. */
|
||||
allowTools?: readonly string[];
|
||||
maxSteps?: number;
|
||||
};
|
||||
|
||||
const READ_ONLY = [
|
||||
'read_file',
|
||||
'glob',
|
||||
'grep',
|
||||
'list_dir',
|
||||
'task',
|
||||
'todo_write',
|
||||
'remember',
|
||||
'recall',
|
||||
'skill',
|
||||
] as const;
|
||||
|
||||
export const VARIANTS: AgentVariant[] = [
|
||||
{
|
||||
name: 'default',
|
||||
summary: 'balanced: full tools, medium thinking',
|
||||
thinking: 'medium',
|
||||
appendix: '',
|
||||
},
|
||||
{
|
||||
name: 'quick',
|
||||
summary: 'small edits: no thinking budget, act immediately',
|
||||
thinking: 'off',
|
||||
maxSteps: 12,
|
||||
appendix:
|
||||
'This is a small, well-scoped task. Do not deliberate: locate the code, make the change, verify it. ' +
|
||||
'Do not write a task list. Do not explore beyond what the change requires.',
|
||||
},
|
||||
{
|
||||
name: 'deep',
|
||||
summary: 'hard problems: maximum thinking, more steps',
|
||||
thinking: 'max',
|
||||
maxSteps: 80,
|
||||
appendix:
|
||||
'This task is hard or its cause is unclear. Form more than one hypothesis before you act and say which one ' +
|
||||
'you are testing. Read enough of the code to be sure rather than guessing. Record findings with remember ' +
|
||||
'so they survive compaction. Report what you verified and what you could not.',
|
||||
},
|
||||
{
|
||||
name: 'plan',
|
||||
summary: 'read-only: investigate and propose, never edit',
|
||||
thinking: 'high',
|
||||
allowTools: READ_ONLY,
|
||||
appendix:
|
||||
'You are in planning mode and have no tools that change anything. Investigate, then produce a plan: ' +
|
||||
'the files to touch, the change in each, the order, and how to verify. Flag anything ambiguous instead of ' +
|
||||
'assuming. Do not describe edits as if you had made them.',
|
||||
},
|
||||
{
|
||||
name: 'review',
|
||||
summary: 'read-only: critique a change, find defects',
|
||||
thinking: 'high',
|
||||
allowTools: READ_ONLY,
|
||||
appendix:
|
||||
'You are reviewing code, not writing it. Look for defects in this order: incorrect behaviour, missing error ' +
|
||||
'handling at trust boundaries, security issues, then clarity. For each finding give file, line, why it is ' +
|
||||
'wrong, and the fix. Say plainly when something is fine. Do not invent problems to fill a report.',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_VARIANT = VARIANTS[0]!;
|
||||
|
||||
export const variantByName = (name: string) => VARIANTS.find((v) => v.name === name);
|
||||
|
||||
/** Variant with an explicit thinking override applied, for `--agent deep --think low`. */
|
||||
export function resolveAgent(name: string | undefined, thinking: string | undefined): AgentVariant {
|
||||
const base = name ? variantByName(name) : DEFAULT_VARIANT;
|
||||
if (!base) throw new Error(`Unknown agent "${name}". Available: ${VARIANTS.map((v) => v.name).join(', ')}`);
|
||||
if (thinking === undefined) return base;
|
||||
if (!isThinkingLevel(thinking)) {
|
||||
throw new Error(`Unknown thinking level "${thinking}". Available: ${THINKING_LEVELS.join(', ')}`);
|
||||
}
|
||||
return { ...base, thinking };
|
||||
}
|
||||
|
||||
export function renderAgent(variant: AgentVariant): string {
|
||||
return variant.appendix ? `\n${variant.appendix}` : '';
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type AskRequest = {
|
||||
question: string;
|
||||
options?: { label: string; detail?: string }[];
|
||||
multiple: boolean;
|
||||
};
|
||||
|
||||
/** Set by the UI. Absent means nothing can answer, so asking is an error. */
|
||||
export type AskFn = (req: AskRequest) => Promise<string[] | undefined>;
|
||||
|
||||
const MAX_OPTIONS = 8;
|
||||
|
||||
/**
|
||||
* Lets the model stop and ask rather than guess.
|
||||
*
|
||||
* Without this a model facing two materially different readings of a request picks
|
||||
* one and writes code for it. The cost of a wrong guess is a whole wasted turn plus
|
||||
* the user's correction, so one question is almost always cheaper.
|
||||
*/
|
||||
export function createAskTool(ask: AskFn | undefined) {
|
||||
return tool({
|
||||
description:
|
||||
'Ask the user a question and wait for the answer. Use it when the request has two or more readings that ' +
|
||||
'lead to materially different work, when a required detail is missing, or to confirm an approach before a ' +
|
||||
'large change. Offer concrete options when you can; omit them for an open question. ' +
|
||||
'Do not use it for things you can determine by reading the code, and do not ask twice about the same thing.',
|
||||
inputSchema: z.object({
|
||||
question: z.string().describe('One specific question. State what you already know, then what you need.'),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string().describe('Short choice, a few words'),
|
||||
detail: z.string().optional().describe('What choosing this implies, including any tradeoff'),
|
||||
}),
|
||||
)
|
||||
.max(MAX_OPTIONS)
|
||||
.optional()
|
||||
.describe('Concrete choices. Put your recommendation first. Omit for an open question.'),
|
||||
multiple: z.boolean().optional().describe('Allow more than one option to be chosen'),
|
||||
}),
|
||||
execute: async ({ question, options, multiple }) => {
|
||||
if (!ask) {
|
||||
throw new Error(
|
||||
'No one is available to answer: this session is running headless. Decide yourself and state the assumption.',
|
||||
);
|
||||
}
|
||||
const answers = await ask({ question, ...(options ? { options } : {}), multiple: multiple ?? false });
|
||||
if (!answers || answers.length === 0) return 'The user dismissed the question without answering. Proceed with your best judgement and say what you assumed.';
|
||||
return `The user answered: ${answers.join(', ')}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const ASK_TOOL_NAME = 'ask';
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env bun
|
||||
import { render } from 'ink';
|
||||
import React from 'react';
|
||||
import type { LanguageModel, ModelMessage } from 'ai';
|
||||
import { resolveAgent, VARIANTS, isThinkingLevel, type AgentVariant } from './agents';
|
||||
import { configPath, loadConfig, missingKeyMessage, resolveModel, writeConfigFile, type Config } from './config';
|
||||
import type { FallbackEvent } from './fallback';
|
||||
import { readStdin, runHeadless } from './headless';
|
||||
import { INIT_PROMPT, loadInstructions } from './instructions';
|
||||
import { connectMcp } from './mcp';
|
||||
import { Memory, KIND_LABEL } from './memory';
|
||||
import { costOf } from './pricing';
|
||||
import { BUILTIN_PLUGINS, DEFAULT_ENABLED } from './plugins-builtin';
|
||||
import { createHost } from './plugins';
|
||||
import { fetchModels, presetById } from './providers';
|
||||
import { Session } from './session';
|
||||
import { loadSkills } from './skills';
|
||||
import * as store from './store';
|
||||
import { createTaskTool } from './subagent';
|
||||
import { VERSION, versionLine } from './version';
|
||||
import { createAskBridge } from './ui/Ask';
|
||||
import { App, createApprovalBridge, createNoticeBus, createSubagentBus, type AppHooks } from './ui/App';
|
||||
|
||||
// SDK warnings go straight to stderr, which tears up the Ink render.
|
||||
(globalThis as { AI_SDK_LOG_WARNINGS?: boolean }).AI_SDK_LOG_WARNINGS = false;
|
||||
|
||||
const HELP = `shiro-neko ${VERSION} - agentic coding CLI
|
||||
|
||||
usage: shiro [options]
|
||||
shiro -p "prompt" headless, prints to stdout
|
||||
cat file | shiro -p prompt read from stdin
|
||||
|
||||
options:
|
||||
-p, --print [prompt] headless mode; requires --yolo for tool use
|
||||
--json with -p, emit one JSON event per line
|
||||
-c, --continue resume the newest session for this directory
|
||||
-r, --resume <id> resume a session by id or id prefix
|
||||
--agent <name> ${VARIANTS.map((v) => v.name).join(' | ')}
|
||||
--think <level> off | low | medium | high | max
|
||||
--provider <anthropic|openai> wire protocol to use (default anthropic)
|
||||
--model <id> model id
|
||||
--base-url <url> OpenAI/Anthropic-compatible endpoint
|
||||
--no-mcp skip MCP servers from the config file
|
||||
--no-subagent omit the task tool
|
||||
--no-instructions ignore AGENTS.md / CLAUDE.md
|
||||
--no-skills ignore builtin and project skills
|
||||
--no-plugins disable all plugins, including the guard
|
||||
--no-memory do not load or write project memory
|
||||
--yolo skip all tool approval prompts
|
||||
-v, --version
|
||||
-h, --help
|
||||
|
||||
first run: start shiro with no key and it opens provider setup, or use /provider anytime.
|
||||
|
||||
config: ${configPath()}
|
||||
{ "provider": "openai", "model": "gpt-5", "apiKey": "...",
|
||||
"agent": "default", "thinking": "medium", "plugins": ["guard", "time"],
|
||||
"mcpServers": { "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] } } }
|
||||
|
||||
env: SHIRO_PROVIDER SHIRO_MODEL SHIRO_BASE_URL SHIRO_API_KEY
|
||||
ANTHROPIC_API_KEY OPENAI_API_KEY
|
||||
|
||||
skills: builtin, plus ~/.shiro-neko/skills/*.md and .shiro/skills/*.md
|
||||
sessions: ${store.sessionsDir()}
|
||||
in-session: /help for the command list`;
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
function flag(...names: string[]): string | undefined {
|
||||
for (const n of names) {
|
||||
const i = argv.indexOf(n);
|
||||
if (i === -1) continue;
|
||||
const next = argv[i + 1];
|
||||
return next && !next.startsWith('-') ? next : '';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const has = (...names: string[]) => names.some((n) => argv.includes(n));
|
||||
|
||||
if (has('-h', '--help')) {
|
||||
console.log(HELP);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (has('-v', '--version')) {
|
||||
console.log(versionLine());
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const providerFlag = flag('--provider');
|
||||
const modelFlag = flag('--model');
|
||||
const baseUrlFlag = flag('--base-url');
|
||||
if (providerFlag) process.env['SHIRO_PROVIDER'] = providerFlag;
|
||||
if (modelFlag) process.env['SHIRO_MODEL'] = modelFlag;
|
||||
if (baseUrlFlag) process.env['SHIRO_BASE_URL'] = baseUrlFlag;
|
||||
|
||||
let cfg = await loadConfig();
|
||||
const yolo = has('--yolo');
|
||||
const headless = flag('-p', '--print') !== undefined;
|
||||
|
||||
// A missing key is fatal for a pipe, but interactively it just means "not set up yet".
|
||||
if (!cfg.apiKey && headless) {
|
||||
console.error(`shiro: ${missingKeyMessage(cfg.provider)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const needsProvider = !cfg.apiKey;
|
||||
const notices = createNoticeBus();
|
||||
const subagents = createSubagentBus();
|
||||
const askBridge = createAskBridge();
|
||||
|
||||
function reportFallback(e: FallbackEvent): void {
|
||||
const line = `endpoint fallback: ${e.from} rejected the request, retrying on ${e.to}\n ${e.reason}`;
|
||||
if (headless) process.stderr.write(`shiro: ${line}\n`);
|
||||
else notices.emit(line);
|
||||
}
|
||||
|
||||
let languageModel: LanguageModel | undefined;
|
||||
if (cfg.apiKey) {
|
||||
try {
|
||||
languageModel = resolveModel(cfg, reportFallback);
|
||||
} catch (e) {
|
||||
console.error(`shiro: ${(e as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let restored: store.SessionRecord | undefined;
|
||||
const resumeArg = flag('-r', '--resume');
|
||||
if (resumeArg) {
|
||||
const id = await store.resolveId(resumeArg);
|
||||
restored = id ? await store.load(id) : undefined;
|
||||
if (!restored) {
|
||||
console.error(`shiro: no session matching "${resumeArg}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (has('-c', '--continue')) {
|
||||
restored = await store.latest(process.cwd());
|
||||
if (!restored) {
|
||||
console.error('shiro: no saved session for this directory');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const mcp = has('--no-mcp') || !cfg.mcpServers ? undefined : await connectMcp(cfg.mcpServers);
|
||||
const instructions = has('--no-instructions') ? [] : await loadInstructions();
|
||||
const skills = has('--no-skills') ? [] : await loadSkills();
|
||||
const promptHistory = await store.loadHistory();
|
||||
|
||||
let agentVariant: AgentVariant;
|
||||
try {
|
||||
agentVariant = resolveAgent(flag('--agent') || cfg.agent, flag('--think') || cfg.thinking);
|
||||
} catch (e) {
|
||||
console.error(`shiro: ${(e as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enabledPlugins = has('--no-plugins') ? [] : (cfg.plugins ?? DEFAULT_ENABLED);
|
||||
const pluginErrors = enabledPlugins
|
||||
.filter((name) => !BUILTIN_PLUGINS.some((p) => p.name === name))
|
||||
.map((name) => ({ plugin: name, message: 'no such plugin' }));
|
||||
const plugins = createHost(
|
||||
BUILTIN_PLUGINS.filter((p) => enabledPlugins.includes(p.name)),
|
||||
pluginErrors,
|
||||
);
|
||||
|
||||
const memory = has('--no-memory') ? undefined : new Memory(process.cwd(), languageModel);
|
||||
if (memory) await memory.load();
|
||||
|
||||
/** Placeholder until /provider supplies a key; it never gets called because the UI gates input. */
|
||||
const unconfiguredModel: LanguageModel = {
|
||||
specificationVersion: 'v4',
|
||||
provider: 'unconfigured',
|
||||
modelId: 'unconfigured',
|
||||
supportedUrls: {},
|
||||
doGenerate: () => Promise.reject(new Error('no provider configured - run /provider')),
|
||||
doStream: () => Promise.reject(new Error('no provider configured - run /provider')),
|
||||
};
|
||||
|
||||
const record: store.SessionRecord = restored ?? {
|
||||
id: store.newId(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
provider: cfg.provider,
|
||||
model: cfg.model,
|
||||
title: 'untitled',
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
messages: [],
|
||||
};
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function persist(messages: ModelMessage[]): Promise<void> {
|
||||
record.messages = messages;
|
||||
record.title = store.titleOf(messages);
|
||||
record.inputTokens = session.inputTokens;
|
||||
record.outputTokens = session.outputTokens;
|
||||
record.notebook = session.notebook.state();
|
||||
const cost = costOf(record.model, session.inputTokens, session.outputTokens);
|
||||
if (cost !== undefined) record.costUsd = cost;
|
||||
await store.save(record);
|
||||
}
|
||||
|
||||
const bridge = createApprovalBridge();
|
||||
const session = new Session({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
askApproval: bridge.ask,
|
||||
yolo,
|
||||
instructions,
|
||||
skills,
|
||||
plugins,
|
||||
agent: agentVariant,
|
||||
// Headless has no one to answer, so the tool is withheld rather than left to hang.
|
||||
...(headless ? {} : { ask: askBridge.ask }),
|
||||
...(memory ? { memory } : {}),
|
||||
...(record.notebook ? { notebook: record.notebook } : {}),
|
||||
...(cfg.maxRetries !== undefined ? { maxRetries: cfg.maxRetries } : {}),
|
||||
extraTools: {
|
||||
...(mcp?.tools ?? {}),
|
||||
...(has('--no-subagent')
|
||||
? {}
|
||||
: {
|
||||
task: createTaskTool({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
...(headless ? {} : { report: subagents.emit }),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
autoApprove: ['task'],
|
||||
messages: [...record.messages],
|
||||
onChange: (messages) => {
|
||||
// Debounced so a long tool loop does not hit the disk on every step.
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => void persist(messages), 400);
|
||||
},
|
||||
});
|
||||
|
||||
async function shutdown(code: number): Promise<never> {
|
||||
clearTimeout(saveTimer);
|
||||
if (session.messages.length > 0) await persist(session.messages);
|
||||
await mcp?.close();
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const printArg = flag('-p', '--print');
|
||||
if (printArg !== undefined) {
|
||||
const prompt = printArg || (await readStdin());
|
||||
if (!prompt) {
|
||||
console.error('shiro: -p needs a prompt argument or piped stdin');
|
||||
await shutdown(1);
|
||||
}
|
||||
if (!yolo) {
|
||||
process.stderr.write('shiro: headless denies write_file, edit_file, bash and mcp tools unless --yolo is passed\n');
|
||||
}
|
||||
const code = await runHeadless({ session, prompt, format: has('--json') ? 'json' : 'text' });
|
||||
await shutdown(code);
|
||||
}
|
||||
|
||||
function applyConfig(next: Config): void {
|
||||
cfg = next;
|
||||
record.provider = next.provider;
|
||||
record.model = next.model;
|
||||
session.setModel(resolveModel(next, reportFallback));
|
||||
}
|
||||
|
||||
const hooks: AppHooks = {
|
||||
sessionId: record.id,
|
||||
config: () => cfg,
|
||||
instructionFiles: () => instructions.map((i) => i.path),
|
||||
initPrompt: INIT_PROMPT,
|
||||
history: promptHistory,
|
||||
recordPrompt: (text) => void store.appendHistory(text),
|
||||
agentName: () => session.agent().name,
|
||||
thinkingLevel: () => session.agent().thinking,
|
||||
switchModel: (id) => {
|
||||
applyConfig({ ...cfg, model: id });
|
||||
return `model is now ${id}`;
|
||||
},
|
||||
switchAgent: (name) => {
|
||||
const next = resolveAgent(name, session.agent().thinking);
|
||||
session.setAgent(next);
|
||||
const scope = next.allowTools ? ` (read-only: ${next.allowTools.length} tools)` : '';
|
||||
return `agent is now ${next.name}, thinking ${next.thinking}${scope}`;
|
||||
},
|
||||
switchThinking: (level) => {
|
||||
if (!isThinkingLevel(level)) throw new Error(`Unknown thinking level "${level}"`);
|
||||
session.setAgent({ ...session.agent(), thinking: level });
|
||||
return `thinking is now ${level}`;
|
||||
},
|
||||
listSkills: () => {
|
||||
if (skills.length === 0) return 'no skills loaded';
|
||||
return skills.map((s) => `${s.name.padEnd(10)} ${s.origin.padEnd(8)} ${s.description}`).join('\n');
|
||||
},
|
||||
listPlugins: () => {
|
||||
const active = plugins.plugins.map((p) => `${p.name.padEnd(8)} ${p.description}`);
|
||||
const failed = plugins.errors.map((e) => `${e.plugin.padEnd(8)} ${e.message}`);
|
||||
if (active.length === 0 && failed.length === 0) return 'no plugins active';
|
||||
return [...active, ...failed].join('\n');
|
||||
},
|
||||
listMemory: async () => {
|
||||
if (!memory) return 'memory is disabled (--no-memory)';
|
||||
const all = await memory.load();
|
||||
if (all.length === 0) return 'nothing remembered about this project yet';
|
||||
return all
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((e) => `(${KIND_LABEL[e.kind]}) ${e.text}${e.hits > 0 ? ` [recalled ${e.hits}x]` : ''}`)
|
||||
.join('\n');
|
||||
},
|
||||
summarizeMemory: async () => {
|
||||
if (!memory) return 'memory is disabled (--no-memory)';
|
||||
const { before, after } = await memory.summarize();
|
||||
return before === after
|
||||
? `memory left as is: ${before} entries, too few unused ones to merge`
|
||||
: `memory compacted: ${before} entries into ${after}`;
|
||||
},
|
||||
applyProvider: async (result) => {
|
||||
const next: Config = {
|
||||
...cfg,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
baseURL: result.baseURL,
|
||||
apiKey: result.apiKey,
|
||||
presetId: result.presetId,
|
||||
};
|
||||
applyConfig(next);
|
||||
const path = await writeConfigFile({
|
||||
provider: next.provider,
|
||||
model: next.model,
|
||||
baseURL: next.baseURL,
|
||||
apiKey: next.apiKey,
|
||||
presetId: next.presetId,
|
||||
});
|
||||
const label = presetById(result.presetId)?.label ?? result.presetId;
|
||||
return `${label} configured with ${result.model}\nsaved to ${path}`;
|
||||
},
|
||||
listModels: async () => {
|
||||
if (!cfg.apiKey) return { models: [], warning: 'no API key set - run /provider' };
|
||||
const preset = presetById(cfg.presetId ?? cfg.provider);
|
||||
const { models, warning } = await fetchModels(
|
||||
{
|
||||
kind: cfg.provider,
|
||||
baseURL: cfg.baseURL ?? '',
|
||||
...(preset?.fallbackModels ? { fallbackModels: preset.fallbackModels } : {}),
|
||||
},
|
||||
cfg.apiKey,
|
||||
);
|
||||
return warning ? { models, warning } : { models };
|
||||
},
|
||||
listSessions: async () => {
|
||||
const all = await store.list(15);
|
||||
if (all.length === 0) return 'no saved sessions';
|
||||
return all
|
||||
.map(
|
||||
(r) =>
|
||||
`${r.id.slice(0, 8)} ${r.updatedAt.slice(0, 16).replace('T', ' ')} ${r.messages.length}msg ${r.title}`,
|
||||
)
|
||||
.join('\n');
|
||||
},
|
||||
resumeSession: async (idOrPrefix) => {
|
||||
const id = await store.resolveId(idOrPrefix);
|
||||
const rec = id ? await store.load(id) : undefined;
|
||||
if (!rec) throw new Error(`no session matching "${idOrPrefix}"`);
|
||||
session.replace(rec.messages);
|
||||
record.id = rec.id;
|
||||
record.title = rec.title;
|
||||
hooks.sessionId = rec.id;
|
||||
return `resumed ${rec.id.slice(0, 8)} (${rec.messages.length} messages): ${rec.title}`;
|
||||
},
|
||||
saveSession: async () => {
|
||||
await persist(session.messages);
|
||||
return `saved ${record.id}`;
|
||||
},
|
||||
};
|
||||
|
||||
const header = [
|
||||
needsProvider
|
||||
? `shiro-neko ${VERSION} no provider configured`
|
||||
: `shiro-neko ${VERSION} ${cfg.provider}/${record.model} session ${record.id.slice(0, 8)}`,
|
||||
`agent: ${agentVariant.name} thinking: ${agentVariant.thinking}`,
|
||||
`cwd: ${process.cwd()}`,
|
||||
restored ? `resumed ${record.messages.length} messages` : undefined,
|
||||
instructions.length > 0
|
||||
? `instructions: ${instructions.map((i) => i.path.split(/[\\/]/).at(-1)).join(', ')}`
|
||||
: 'no AGENTS.md found - /init writes one',
|
||||
skills.length > 0 ? `skills: ${skills.map((s) => s.name).join(', ')}` : undefined,
|
||||
plugins.plugins.length > 0 ? `plugins: ${plugins.plugins.map((p) => p.name).join(', ')}` : undefined,
|
||||
...plugins.errors.map((e) => `plugin ${e.plugin}: ${e.message}`),
|
||||
memory && memory.all().length > 0 ? `memory: ${memory.all().length} notes about this project` : undefined,
|
||||
mcp && Object.keys(mcp.tools).length > 0 ? `mcp: ${Object.keys(mcp.tools).length} tools` : undefined,
|
||||
...(mcp?.errors ?? []).map((e) => `mcp ${e.server} failed: ${e.message}`),
|
||||
yolo ? 'approvals: OFF (--yolo)' : 'approvals: on for write_file, edit_file, bash, mcp__*',
|
||||
'/help for commands',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
const app = render(
|
||||
<App
|
||||
session={session}
|
||||
bridge={bridge}
|
||||
header={header}
|
||||
hooks={hooks}
|
||||
notices={notices}
|
||||
askBridge={askBridge}
|
||||
subagents={subagents}
|
||||
needsProvider={needsProvider}
|
||||
/>,
|
||||
);
|
||||
await app.waitUntilExit();
|
||||
await shutdown(0);
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
export type CommandAction =
|
||||
| { type: 'none' }
|
||||
| { type: 'prompt'; text: string }
|
||||
| { type: 'exit' }
|
||||
| { type: 'clear' }
|
||||
| { type: 'compact' }
|
||||
| { type: 'tools' }
|
||||
| { type: 'cost' }
|
||||
| { type: 'sessions' }
|
||||
| { type: 'save' }
|
||||
| { type: 'provider' }
|
||||
| { type: 'models' }
|
||||
| { type: 'init' }
|
||||
| { type: 'context' }
|
||||
| { type: 'todos' }
|
||||
| { type: 'notes' }
|
||||
| { type: 'skills' }
|
||||
| { type: 'plugins' }
|
||||
| { type: 'memory' }
|
||||
| { type: 'agent'; agent?: string }
|
||||
| { type: 'think'; level?: string }
|
||||
| { type: 'info'; text: string }
|
||||
| { type: 'model'; model: string }
|
||||
| { type: 'resume'; id: string }
|
||||
| { type: 'unknown'; name: string };
|
||||
|
||||
export type CommandSpec = {
|
||||
name: string;
|
||||
/** Extra names that resolve to the same command, hidden from the menu. */
|
||||
aliases?: string[];
|
||||
arg?: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
/** Single source of truth for the menu, `/help`, and the parser. */
|
||||
export const COMMANDS: CommandSpec[] = [
|
||||
{ name: 'help', aliases: ['?'], summary: 'list these commands' },
|
||||
{ name: 'agent', arg: '[name]', summary: 'switch agent: default, quick, deep, plan, review' },
|
||||
{ name: 'think', arg: '[level]', summary: 'thinking level: off, low, medium, high, max' },
|
||||
{ name: 'provider', aliases: ['login'], summary: 'set up a provider: pick, paste API key, choose model' },
|
||||
{ name: 'models', summary: 'pick a model from the current provider' },
|
||||
{ name: 'model', arg: '<id>', summary: 'switch model by name' },
|
||||
{ name: 'skills', summary: 'list loaded skills' },
|
||||
{ name: 'plugins', summary: 'list active plugins' },
|
||||
{ name: 'init', summary: 'have the agent write AGENTS.md for this project' },
|
||||
{ name: 'context', summary: 'show which instruction files are loaded' },
|
||||
{ name: 'todos', summary: "show the agent's task list" },
|
||||
{ name: 'notes', summary: 'show what the agent remembers about this project' },
|
||||
{ name: 'memory', summary: 'compact the project memory with the model' },
|
||||
{ name: 'tools', summary: 'list available tools' },
|
||||
{ name: 'compact', summary: 'replace history with a model-written summary' },
|
||||
{ name: 'cost', summary: 'tokens and estimated spend this session' },
|
||||
{ name: 'sessions', summary: 'list saved sessions' },
|
||||
{ name: 'resume', arg: '<id>', summary: 'load a saved session' },
|
||||
{ name: 'save', summary: 'write the session to disk now' },
|
||||
{ name: 'clear', summary: 'clear the transcript and history' },
|
||||
{ name: 'exit', aliases: ['quit'], summary: 'quit' },
|
||||
];
|
||||
|
||||
const usage = (c: CommandSpec) => `/${c.name}${c.arg ? ` ${c.arg}` : ''}`;
|
||||
|
||||
export const HELP = [
|
||||
...COMMANDS.map((c) => `${usage(c).padEnd(18)} ${c.summary}`),
|
||||
'',
|
||||
'esc interrupt the running turn',
|
||||
'tab complete the highlighted command',
|
||||
'up / down recall earlier prompts',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Commands whose name starts with the typed prefix, for the `/` menu.
|
||||
* An exact name sorts first so pressing enter on `/model` cannot run `/models`.
|
||||
* Aliases stay hidden to keep the list short.
|
||||
*/
|
||||
export function matchCommands(input: string): CommandSpec[] {
|
||||
if (!input.startsWith('/')) return [];
|
||||
const typed = input.slice(1).toLowerCase();
|
||||
if (typed.includes(' ')) return [];
|
||||
const hits = COMMANDS.filter((c) => c.name.startsWith(typed));
|
||||
const exact = hits.findIndex((c) => c.name === typed);
|
||||
return exact > 0 ? [hits[exact]!, ...hits.filter((_, i) => i !== exact)] : hits;
|
||||
}
|
||||
|
||||
/** True while the input is a bare command name being typed, so the menu should show. */
|
||||
export const isMenuOpen = (input: string) => input.startsWith('/') && !input.includes(' ');
|
||||
|
||||
/** Pure parser: no IO, so the TUI and headless mode share one definition. */
|
||||
export function parseCommand(raw: string): CommandAction {
|
||||
const input = raw.trim();
|
||||
if (!input) return { type: 'none' };
|
||||
if (!input.startsWith('/')) return { type: 'prompt', text: input };
|
||||
|
||||
const [name = '', ...rest] = input.slice(1).split(/\s+/);
|
||||
const arg = rest.join(' ').trim();
|
||||
|
||||
switch (name) {
|
||||
case 'help':
|
||||
case '?':
|
||||
return { type: 'info', text: HELP };
|
||||
case 'exit':
|
||||
case 'quit':
|
||||
return { type: 'exit' };
|
||||
case 'clear':
|
||||
return { type: 'clear' };
|
||||
case 'compact':
|
||||
return { type: 'compact' };
|
||||
case 'tools':
|
||||
return { type: 'tools' };
|
||||
case 'cost':
|
||||
return { type: 'cost' };
|
||||
case 'sessions':
|
||||
return { type: 'sessions' };
|
||||
case 'save':
|
||||
return { type: 'save' };
|
||||
case 'provider':
|
||||
case 'login':
|
||||
return { type: 'provider' };
|
||||
case 'models':
|
||||
return { type: 'models' };
|
||||
case 'init':
|
||||
return { type: 'init' };
|
||||
case 'context':
|
||||
return { type: 'context' };
|
||||
case 'todos':
|
||||
return { type: 'todos' };
|
||||
case 'notes':
|
||||
return { type: 'notes' };
|
||||
case 'skills':
|
||||
return { type: 'skills' };
|
||||
case 'plugins':
|
||||
return { type: 'plugins' };
|
||||
case 'memory':
|
||||
return { type: 'memory' };
|
||||
case 'agent':
|
||||
return arg ? { type: 'agent', agent: arg } : { type: 'agent' };
|
||||
case 'think':
|
||||
return arg ? { type: 'think', level: arg } : { type: 'think' };
|
||||
case 'model':
|
||||
return arg ? { type: 'model', model: arg } : { type: 'models' };
|
||||
case 'resume':
|
||||
return arg ? { type: 'resume', id: arg } : { type: 'info', text: 'usage: /resume <session-id>' };
|
||||
default:
|
||||
return { type: 'unknown', name };
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { withFallback, type FallbackEvent } from './fallback';
|
||||
import type { McpServerConfig } from './mcp';
|
||||
|
||||
export type ProviderName = 'anthropic' | 'openai';
|
||||
|
||||
export type Config = {
|
||||
provider: ProviderName;
|
||||
model: string;
|
||||
baseURL?: string;
|
||||
apiKey?: string;
|
||||
/** Preset id from providers.ts, kept so /provider can show what is configured. */
|
||||
presetId?: string;
|
||||
/** Retries per model call for transient failures. SDK default is 2. */
|
||||
maxRetries?: number;
|
||||
/** Default agent variant name. */
|
||||
agent?: string;
|
||||
/** Default thinking level. */
|
||||
thinking?: string;
|
||||
/** Plugin names to enable; omit for the default set. */
|
||||
plugins?: string[];
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
};
|
||||
|
||||
const configPath = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'config.json');
|
||||
|
||||
const DEFAULT_MODEL: Record<ProviderName, string> = {
|
||||
anthropic: 'claude-sonnet-4-5',
|
||||
openai: 'gpt-5',
|
||||
};
|
||||
|
||||
const DEFAULT_BASE_URL: Record<ProviderName, string> = {
|
||||
anthropic: 'https://api.anthropic.com/v1',
|
||||
openai: 'https://api.openai.com/v1',
|
||||
};
|
||||
|
||||
/** Env key checked per provider when no explicit apiKey is configured. */
|
||||
const ENV_KEY: Record<ProviderName, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
};
|
||||
|
||||
function isProvider(v: unknown): v is ProviderName {
|
||||
return v === 'anthropic' || v === 'openai';
|
||||
}
|
||||
|
||||
/** Raw file contents, without env overlay. Used when rewriting the file. */
|
||||
export async function readConfigFile(): Promise<Partial<Config>> {
|
||||
const f = Bun.file(configPath());
|
||||
if (!(await f.exists())) return {};
|
||||
try {
|
||||
const parsed: unknown = await f.json();
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Partial<Config>) : {};
|
||||
} catch {
|
||||
throw new Error(`${configPath()} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Merges patch into the config file, preserving unrelated keys such as mcpServers. */
|
||||
export async function writeConfigFile(patch: Partial<Config>): Promise<string> {
|
||||
const merged = { ...(await readConfigFile()), ...patch };
|
||||
await Bun.write(configPath(), `${JSON.stringify(merged, null, 2)}\n`);
|
||||
return configPath();
|
||||
}
|
||||
|
||||
/** File config, then env overrides. Env wins so `SHIRO_MODEL=x shiro` works. */
|
||||
export async function loadConfig(): Promise<Config> {
|
||||
const file = await readConfigFile();
|
||||
|
||||
const envProvider = process.env['SHIRO_PROVIDER'];
|
||||
const provider = isProvider(envProvider) ? envProvider : isProvider(file.provider) ? file.provider : 'anthropic';
|
||||
|
||||
return {
|
||||
provider,
|
||||
model: process.env['SHIRO_MODEL'] ?? file.model ?? DEFAULT_MODEL[provider],
|
||||
baseURL: process.env['SHIRO_BASE_URL'] ?? file.baseURL ?? DEFAULT_BASE_URL[provider],
|
||||
apiKey: process.env['SHIRO_API_KEY'] ?? file.apiKey ?? process.env[ENV_KEY[provider]],
|
||||
...(file.presetId ? { presetId: file.presetId } : {}),
|
||||
...(file.maxRetries !== undefined ? { maxRetries: file.maxRetries } : {}),
|
||||
...(file.agent ? { agent: file.agent } : {}),
|
||||
...(file.thinking ? { thinking: file.thinking } : {}),
|
||||
...(Array.isArray(file.plugins) ? { plugins: file.plugins } : {}),
|
||||
...(file.mcpServers ? { mcpServers: file.mcpServers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function missingKeyMessage(provider: ProviderName): string {
|
||||
return `No API key for provider "${provider}". Run shiro and use /provider to set one, or set ${ENV_KEY[provider]} / SHIRO_API_KEY, or add "apiKey" to ${configPath()}`;
|
||||
}
|
||||
|
||||
const isOfficialOpenAI = (baseURL: string | undefined) =>
|
||||
!baseURL || /^https:\/\/api\.openai\.com(\/|$)/.test(baseURL);
|
||||
|
||||
/**
|
||||
* Newer OpenAI reasoning models refuse function tools on /v1/chat/completions and
|
||||
* demand /v1/responses. Rather than guess per model id, build both and let
|
||||
* withFallback switch when the endpoint rejects the request shape.
|
||||
*/
|
||||
export function resolveModel(cfg: Config, onFallback?: (e: FallbackEvent) => void): LanguageModel {
|
||||
if (!cfg.apiKey) throw new Error(missingKeyMessage(cfg.provider));
|
||||
|
||||
if (cfg.provider === 'anthropic') {
|
||||
return createAnthropic({ apiKey: cfg.apiKey, baseURL: cfg.baseURL })(cfg.model);
|
||||
}
|
||||
|
||||
const chat = createOpenAICompatible({
|
||||
name: 'openai',
|
||||
apiKey: cfg.apiKey,
|
||||
baseURL: cfg.baseURL ?? DEFAULT_BASE_URL.openai,
|
||||
})(cfg.model);
|
||||
|
||||
if (!isOfficialOpenAI(cfg.baseURL)) return chat;
|
||||
|
||||
const openai = createOpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseURL });
|
||||
return withFallback([chat, openai.responses(cfg.model)], onFallback);
|
||||
}
|
||||
|
||||
export { configPath, ENV_KEY };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { APICallError } from 'ai';
|
||||
import type { LanguageModelV4 } from '@ai-sdk/provider';
|
||||
|
||||
export type FallbackEvent = {
|
||||
from: string;
|
||||
to: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/** Marks a model built by withFallback, so callers can assert the chain is active. */
|
||||
export const FALLBACK_CHAIN = Symbol.for('shiro.fallbackChain');
|
||||
|
||||
export const fallbackChainOf = (model: unknown): string[] | undefined =>
|
||||
(model as Record<symbol, string[] | undefined>)[FALLBACK_CHAIN];
|
||||
|
||||
/**
|
||||
* Status codes that mean "this endpoint cannot serve this request", as opposed to
|
||||
* "try again later". Only these justify switching to a different API shape;
|
||||
* 401/403/429/5xx are either permanent or the SDK's own retry territory.
|
||||
*/
|
||||
const SHAPE_MISMATCH = new Set([400, 404, 405, 415, 422, 501]);
|
||||
|
||||
function shouldFallback(error: unknown): string | undefined {
|
||||
if (!APICallError.isInstance(error)) return undefined;
|
||||
if (error.isRetryable) return undefined;
|
||||
if (error.statusCode === undefined || !SHAPE_MISMATCH.has(error.statusCode)) return undefined;
|
||||
return `${error.statusCode}: ${error.message}`;
|
||||
}
|
||||
|
||||
const label = (m: LanguageModelV4) => `${m.provider}/${m.modelId}`;
|
||||
|
||||
/**
|
||||
* Presents several models as one, walking the list when an endpoint rejects the
|
||||
* request shape. Built for OpenAI models that only accept function tools on
|
||||
* /v1/responses, not /v1/chat/completions.
|
||||
*
|
||||
* ponytail: only a rejected doStream/doGenerate triggers the switch, not an error
|
||||
* emitted mid-stream, since tokens already delivered to the UI cannot be unsent.
|
||||
* Revisit if a provider starts returning 400s inside the stream body.
|
||||
*/
|
||||
export function withFallback(models: LanguageModelV4[], onFallback?: (e: FallbackEvent) => void): LanguageModelV4 {
|
||||
const [primary] = models;
|
||||
if (!primary) throw new Error('withFallback needs at least one model');
|
||||
if (models.length === 1) return primary;
|
||||
|
||||
// Sticky: once an endpoint rejects the request shape it will reject every later
|
||||
// step too, so start from the one that worked instead of re-probing each time.
|
||||
let start = 0;
|
||||
const reported = new Set<string>();
|
||||
|
||||
async function attempt<T>(op: (model: LanguageModelV4) => PromiseLike<T>): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let i = start; i < models.length; i++) {
|
||||
const model = models[i]!;
|
||||
try {
|
||||
return await op(model);
|
||||
} catch (error) {
|
||||
const reason = shouldFallback(error);
|
||||
const next = models[i + 1];
|
||||
if (!reason || !next) throw error;
|
||||
lastError = error;
|
||||
start = i + 1;
|
||||
const key = `${label(model)}->${label(next)}`;
|
||||
if (!reported.has(key)) {
|
||||
reported.add(key);
|
||||
onFallback?.({ from: label(model), to: label(next), reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const wrapped: LanguageModelV4 = {
|
||||
specificationVersion: 'v4',
|
||||
provider: primary.provider,
|
||||
modelId: primary.modelId,
|
||||
supportedUrls: primary.supportedUrls,
|
||||
doGenerate: (options) => attempt((m) => m.doGenerate(options)),
|
||||
doStream: (options) => attempt((m) => m.doStream(options)),
|
||||
};
|
||||
Object.defineProperty(wrapped, FALLBACK_CHAIN, { value: models.map(label), enumerable: false });
|
||||
return wrapped;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { AgentEvent, Session } from './session';
|
||||
|
||||
export type HeadlessOptions = {
|
||||
session: Session;
|
||||
prompt: string;
|
||||
/** 'text' streams assistant text only; 'json' emits one event object per line. */
|
||||
format?: 'text' | 'json';
|
||||
out?: (chunk: string) => void;
|
||||
};
|
||||
|
||||
const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
/**
|
||||
* JSON.stringify turns an Error into `{}`, which would make --json useless for
|
||||
* diagnosing a failure, so error payloads are flattened to a message string.
|
||||
*/
|
||||
function serialize(ev: AgentEvent): string {
|
||||
if (ev.type === 'error') return JSON.stringify({ type: 'error', error: message(ev.error) });
|
||||
if (ev.type === 'tool-error') {
|
||||
return JSON.stringify({ type: 'tool-error', id: ev.id, name: ev.name, error: message(ev.error) });
|
||||
}
|
||||
return JSON.stringify(ev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive run for pipes and CI. There is no terminal to prompt on, so the
|
||||
* Session must already be constructed with yolo or every mutating call gets denied.
|
||||
* Returns a process exit code.
|
||||
*/
|
||||
export async function runHeadless({ session, prompt, format = 'text', out }: HeadlessOptions): Promise<number> {
|
||||
const write = out ?? ((s: string) => process.stdout.write(s));
|
||||
let failed = false;
|
||||
|
||||
for await (const ev of session.send(prompt)) {
|
||||
if (format === 'json') {
|
||||
write(`${serialize(ev)}\n`);
|
||||
if (ev.type === 'error') failed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (ev.type) {
|
||||
case 'text':
|
||||
write(ev.text);
|
||||
break;
|
||||
case 'tool-call':
|
||||
process.stderr.write(`[tool] ${ev.name} ${JSON.stringify(ev.input)}\n`);
|
||||
break;
|
||||
case 'tool-denied':
|
||||
process.stderr.write(`[denied] ${ev.name} (run with --yolo to allow tool use in headless mode)\n`);
|
||||
break;
|
||||
case 'tool-error':
|
||||
process.stderr.write(`[tool-error] ${ev.name}: ${message(ev.error)}\n`);
|
||||
break;
|
||||
case 'notice':
|
||||
process.stderr.write(`[notice] ${ev.text}\n`);
|
||||
break;
|
||||
case 'compacted':
|
||||
process.stderr.write(`[compacted] ${ev.before} messages pruned to ${ev.after}\n`);
|
||||
break;
|
||||
case 'error':
|
||||
process.stderr.write(`[error] ${message(ev.error)}\n`);
|
||||
failed = true;
|
||||
break;
|
||||
case 'done':
|
||||
write('\n');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function readStdin(): Promise<string> {
|
||||
if (process.stdin.isTTY) return '';
|
||||
return (await Bun.stdin.text()).trim();
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { readdir as readdirFs } from 'node:fs/promises';
|
||||
|
||||
const ALWAYS_SKIP = ['.git', 'node_modules'];
|
||||
|
||||
type Rule = {
|
||||
/** Directory the rule was declared in, relative and posix-separated. */
|
||||
base: string;
|
||||
negated: boolean;
|
||||
dirOnly: boolean;
|
||||
re: RegExp;
|
||||
};
|
||||
|
||||
const posix = (p: string) => p.replaceAll('\\', '/');
|
||||
|
||||
/**
|
||||
* Translates one gitignore pattern into a regex over posix-relative paths.
|
||||
* Supports `!` negation, trailing `/`, leading `/` anchoring, `*`, `?`, and `**`.
|
||||
*/
|
||||
function compile(pattern: string, base: string): Rule | undefined {
|
||||
let body = pattern.trim();
|
||||
if (!body || body.startsWith('#')) return undefined;
|
||||
|
||||
const negated = body.startsWith('!');
|
||||
if (negated) body = body.slice(1);
|
||||
|
||||
const dirOnly = body.endsWith('/');
|
||||
if (dirOnly) body = body.slice(0, -1);
|
||||
|
||||
const anchored = body.startsWith('/') || body.slice(0, -1).includes('/');
|
||||
if (body.startsWith('/')) body = body.slice(1);
|
||||
if (!body) return undefined;
|
||||
|
||||
let re = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i]!;
|
||||
if (ch === '*') {
|
||||
if (body[i + 1] === '*') {
|
||||
// `**/` spans any number of directories, bare `**` spans anything.
|
||||
if (body[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 2;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
}
|
||||
} else if (ch === '?') re += '[^/]';
|
||||
else if ('.+^${}()|[]\\'.includes(ch)) re += `\\${ch}`;
|
||||
else re += ch;
|
||||
}
|
||||
|
||||
// An unanchored pattern matches at any depth; both forms also match everything
|
||||
// beneath a matched directory.
|
||||
const prefix = anchored ? '' : '(?:.*/)?';
|
||||
return { base, negated, dirOnly, re: new RegExp(`^${prefix}${re}(?:/.*)?$`) };
|
||||
}
|
||||
|
||||
async function rulesIn(root: string, dir: string): Promise<Rule[]> {
|
||||
const base = posix(relative(root, dir));
|
||||
const out: Rule[] = [];
|
||||
for (const name of ['.gitignore', '.shiroignore']) {
|
||||
const file = Bun.file(join(dir, name));
|
||||
if (!(await file.exists())) continue;
|
||||
for (const line of (await file.text()).split('\n')) {
|
||||
const rule = compile(line, base);
|
||||
if (rule) out.push(rule);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ignored(relPath: string, isDir: boolean, rules: Rule[]): boolean {
|
||||
let hit = false;
|
||||
for (const rule of rules) {
|
||||
if (rule.dirOnly && !isDir) continue;
|
||||
const scoped = rule.base ? (relPath.startsWith(`${rule.base}/`) ? relPath.slice(rule.base.length + 1) : undefined) : relPath;
|
||||
if (scoped === undefined) continue;
|
||||
// Later rules win, which is how git resolves a negation after an ignore.
|
||||
if (rule.re.test(scoped)) hit = !rule.negated;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
export type WalkOptions = {
|
||||
root?: string;
|
||||
/** Include files git would ignore. */
|
||||
noIgnore?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Yields workspace-relative posix paths, skipping .git, node_modules, and anything
|
||||
* .gitignore or .shiroignore excludes. Nested ignore files are honoured, so a
|
||||
* `dist/` rule in a subpackage only applies inside it.
|
||||
*/
|
||||
export async function* walk(options: WalkOptions = {}): AsyncGenerator<string> {
|
||||
const root = resolve(options.root ?? process.cwd());
|
||||
const limit = options.limit ?? Infinity;
|
||||
let yielded = 0;
|
||||
|
||||
const queue: { dir: string; rules: Rule[] }[] = [
|
||||
{ dir: root, rules: options.noIgnore ? [] : await rulesIn(root, root) },
|
||||
];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { dir, rules } = queue.shift()!;
|
||||
let entries: Entry[];
|
||||
try {
|
||||
entries = (await readdirFs(dir, { withFileTypes: true })).map((d) => ({
|
||||
name: d.name,
|
||||
isDirectory: d.isDirectory(),
|
||||
}));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (ALWAYS_SKIP.includes(entry.name)) continue;
|
||||
const full = join(dir, entry.name);
|
||||
const rel = posix(relative(root, full));
|
||||
if (!options.noIgnore && ignored(rel, entry.isDirectory, rules)) continue;
|
||||
|
||||
if (entry.isDirectory) {
|
||||
const nested = options.noIgnore ? rules : [...rules, ...(await rulesIn(root, full))];
|
||||
queue.push({ dir: full, rules: nested });
|
||||
} else {
|
||||
yield rel;
|
||||
if (++yielded >= limit) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Entry = { name: string; isDirectory: boolean };
|
||||
|
||||
/** Resolves a model-supplied path inside the workspace, rejecting escapes. */
|
||||
export function jail(p: string, root = process.cwd()): string {
|
||||
const abs = isAbsolute(p) ? resolve(p) : resolve(root, p);
|
||||
const rel = relative(resolve(root), abs);
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) {
|
||||
throw new Error(`Path escapes workspace: ${p}`);
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
export { posix };
|
||||
@@ -0,0 +1,71 @@
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
const NAMES = ['AGENTS.md', 'CLAUDE.md', '.shiro.md'];
|
||||
/** Cap per file so one huge doc cannot crowd out the conversation. */
|
||||
const MAX_CHARS = 12_000;
|
||||
|
||||
export type Instructions = { path: string; text: string }[];
|
||||
|
||||
/**
|
||||
* Collects project instruction files from the git root down to cwd, outermost
|
||||
* first so a nested file's rules read as refinements of the ones above it.
|
||||
* Stops at the git root, or the filesystem root when there is no repo.
|
||||
*/
|
||||
export async function loadInstructions(cwd = process.cwd()): Promise<Instructions> {
|
||||
const dirs: string[] = [];
|
||||
let dir = resolve(cwd);
|
||||
while (true) {
|
||||
dirs.unshift(dir);
|
||||
if (await Bun.file(join(dir, '.git', 'HEAD')).exists()) break;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
const found: Instructions = [];
|
||||
const seen = new Set<string>();
|
||||
for (const d of dirs) {
|
||||
for (const name of NAMES) {
|
||||
const path = join(d, name);
|
||||
if (seen.has(path)) continue;
|
||||
const file = Bun.file(path);
|
||||
if (!(await file.exists())) continue;
|
||||
seen.add(path);
|
||||
const text = (await file.text()).trim();
|
||||
if (text) found.push({ path, text: text.slice(0, MAX_CHARS) });
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function formatInstructions(instructions: Instructions, cwd = process.cwd()): string {
|
||||
if (instructions.length === 0) return '';
|
||||
const blocks = instructions.map(({ path, text }) => {
|
||||
const label = path.startsWith(cwd) ? path.slice(cwd.length + 1) || path : path;
|
||||
return `--- ${label} ---\n${text}`;
|
||||
});
|
||||
return [
|
||||
'',
|
||||
'Project instructions (from the files below). Treat these as standing orders from the user;',
|
||||
'they override your defaults but never your safety rules.',
|
||||
'',
|
||||
...blocks,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const INSTRUCTION_NAMES = NAMES;
|
||||
|
||||
export const INIT_PROMPT = `Write an AGENTS.md at the workspace root that will orient a coding agent joining this project cold.
|
||||
|
||||
Investigate first: read the manifest, the config files, the entry points, and a couple of representative
|
||||
source files. Run the test and build commands if that is the only way to learn how they are invoked.
|
||||
|
||||
Then write AGENTS.md covering only what you actually verified:
|
||||
- what this project is, in two or three sentences
|
||||
- the exact commands for install, build, test, typecheck, lint
|
||||
- the layout: which directory holds what
|
||||
- conventions a newcomer would otherwise get wrong: naming, error handling, module boundaries, test style
|
||||
- anything surprising or easy to break
|
||||
|
||||
Keep it under 100 lines. No filler sections, no "best practices" boilerplate, nothing you did not confirm
|
||||
by reading the code. If a section would be guesswork, leave it out.`;
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
export type Span = { text: string; bold?: boolean; italic?: boolean; code?: boolean; strike?: boolean; link?: boolean };
|
||||
|
||||
export type Block =
|
||||
| { kind: 'heading'; level: number; spans: Span[] }
|
||||
| { kind: 'paragraph'; spans: Span[] }
|
||||
| { kind: 'bullet'; indent: number; marker: string; spans: Span[] }
|
||||
| { kind: 'quote'; spans: Span[] }
|
||||
| { kind: 'code'; language: string; lines: string[] }
|
||||
| { kind: 'rule' }
|
||||
| { kind: 'blank' };
|
||||
|
||||
const INLINE =
|
||||
/(`+)([\s\S]*?)\1|\*\*([\s\S]+?)\*\*|__([\s\S]+?)__|~~([\s\S]+?)~~|(?<![A-Za-z0-9_])_([^\s_][\s\S]*?)_(?![A-Za-z0-9_])|\*([^\s*][\s\S]*?)\*|\[([^\]]+)\]\(([^)]+)\)/;
|
||||
|
||||
/**
|
||||
* Inline markdown to styled spans.
|
||||
*
|
||||
* Code spans are matched first and their contents are never re-scanned, so
|
||||
* `` `**not bold**` `` stays literal — the mistake a naive replace-based
|
||||
* renderer makes on every code sample an agent prints. Underscore emphasis is
|
||||
* also required to sit at a word boundary, so `snake_case_name` survives.
|
||||
*/
|
||||
export function parseInline(input: string): Span[] {
|
||||
const spans: Span[] = [];
|
||||
let rest = input;
|
||||
|
||||
while (rest.length > 0) {
|
||||
const m = INLINE.exec(rest);
|
||||
if (!m || m.index === undefined) {
|
||||
spans.push({ text: rest });
|
||||
break;
|
||||
}
|
||||
|
||||
if (m.index > 0) spans.push({ text: rest.slice(0, m.index) });
|
||||
|
||||
if (m[2] !== undefined) spans.push({ text: m[2].trim(), code: true });
|
||||
else if (m[3] !== undefined) spans.push(...parseInline(m[3]).map((s) => ({ ...s, bold: true })));
|
||||
else if (m[4] !== undefined) spans.push(...parseInline(m[4]).map((s) => ({ ...s, bold: true })));
|
||||
else if (m[5] !== undefined) spans.push(...parseInline(m[5]).map((s) => ({ ...s, strike: true })));
|
||||
else if (m[6] !== undefined) spans.push(...parseInline(m[6]).map((s) => ({ ...s, italic: true })));
|
||||
else if (m[7] !== undefined) spans.push(...parseInline(m[7]).map((s) => ({ ...s, italic: true })));
|
||||
else if (m[8] !== undefined) spans.push({ text: m[8], link: true });
|
||||
|
||||
rest = rest.slice(m.index + m[0].length);
|
||||
}
|
||||
|
||||
return spans.filter((s) => s.text.length > 0);
|
||||
}
|
||||
|
||||
const FENCE = /^\s*(```+|~~~+)\s*([\w+-]*)\s*$/;
|
||||
const HEADING = /^(#{1,6})\s+(.*)$/;
|
||||
const BULLET = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
|
||||
const QUOTE = /^\s*>\s?(.*)$/;
|
||||
const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
|
||||
|
||||
/**
|
||||
* Line-based markdown parser covering what an agent actually emits: headings,
|
||||
* fences, lists, quotes, rules, and inline styling. Not CommonMark — no nested
|
||||
* blocks, tables, or reference links, none of which appear in agent replies.
|
||||
*/
|
||||
export function parseMarkdown(input: string): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
const lines = input.replace(/\r\n/g, '\n').split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
|
||||
const fence = FENCE.exec(line);
|
||||
if (fence) {
|
||||
const closer = fence[1]!;
|
||||
const body: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !new RegExp(`^\\s*${closer[0]}{${closer.length},}\\s*$`).test(lines[i]!)) {
|
||||
body.push(lines[i]!);
|
||||
i++;
|
||||
}
|
||||
blocks.push({ kind: 'code', language: fence[2] ?? '', lines: body });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim().length === 0) {
|
||||
if (blocks.at(-1)?.kind !== 'blank') blocks.push({ kind: 'blank' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RULE.test(line)) {
|
||||
blocks.push({ kind: 'rule' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = HEADING.exec(line);
|
||||
if (heading) {
|
||||
blocks.push({ kind: 'heading', level: heading[1]!.length, spans: parseInline(heading[2]!) });
|
||||
continue;
|
||||
}
|
||||
|
||||
const bullet = BULLET.exec(line);
|
||||
if (bullet) {
|
||||
blocks.push({
|
||||
kind: 'bullet',
|
||||
indent: Math.floor(bullet[1]!.length / 2),
|
||||
marker: /\d/.test(bullet[2]!) ? bullet[2]! : '-',
|
||||
spans: parseInline(bullet[3]!),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const quote = QUOTE.exec(line);
|
||||
if (quote) {
|
||||
blocks.push({ kind: 'quote', spans: parseInline(quote[1]!) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Consecutive plain lines join into one paragraph so wrapping is the terminal's job.
|
||||
const previous = blocks.at(-1);
|
||||
if (previous?.kind === 'paragraph') {
|
||||
previous.spans.push({ text: ' ' }, ...parseInline(line.trim()));
|
||||
} else {
|
||||
blocks.push({ kind: 'paragraph', spans: parseInline(line.trim()) });
|
||||
}
|
||||
}
|
||||
|
||||
while (blocks.at(-1)?.kind === 'blank') blocks.pop();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Plain text with the markup removed, for widths and non-styled surfaces. */
|
||||
export function toPlainText(blocks: Block[]): string {
|
||||
return blocks
|
||||
.map((b) => {
|
||||
switch (b.kind) {
|
||||
case 'code':
|
||||
return b.lines.join('\n');
|
||||
case 'rule':
|
||||
return '---';
|
||||
case 'blank':
|
||||
return '';
|
||||
case 'bullet':
|
||||
return `${' '.repeat(b.indent)}${b.marker} ${b.spans.map((s) => s.text).join('')}`;
|
||||
case 'heading':
|
||||
return `${'#'.repeat(b.level)} ${b.spans.map((s) => s.text).join('')}`;
|
||||
default:
|
||||
return b.spans.map((s) => s.text).join('');
|
||||
}
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { createMCPClient, type MCPClient } from '@ai-sdk/mcp';
|
||||
import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio';
|
||||
import type { ToolSet } from 'ai';
|
||||
|
||||
export type McpServerConfig =
|
||||
| { command: string; args?: string[]; env?: Record<string, string>; cwd?: string }
|
||||
| { url: string; type?: 'http' | 'sse'; headers?: Record<string, string> };
|
||||
|
||||
export type McpHandle = {
|
||||
tools: ToolSet;
|
||||
errors: { server: string; message: string }[];
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
const isRemote = (c: McpServerConfig): c is Extract<McpServerConfig, { url: string }> => 'url' in c;
|
||||
|
||||
/**
|
||||
* Connects every configured server and namespaces its tools as `mcp__<server>__<tool>`
|
||||
* so two servers exposing `search` cannot silently shadow each other.
|
||||
* A server that fails to start is reported, never fatal.
|
||||
*/
|
||||
export async function connectMcp(servers: Record<string, McpServerConfig>): Promise<McpHandle> {
|
||||
const clients: MCPClient[] = [];
|
||||
const tools: ToolSet = {};
|
||||
const errors: McpHandle['errors'] = [];
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(servers).map(async ([name, cfg]) => {
|
||||
try {
|
||||
const client = await createMCPClient({
|
||||
transport: isRemote(cfg)
|
||||
? { type: cfg.type ?? 'http', url: cfg.url, ...(cfg.headers ? { headers: cfg.headers } : {}) }
|
||||
: new Experimental_StdioMCPTransport({
|
||||
command: cfg.command,
|
||||
...(cfg.args ? { args: cfg.args } : {}),
|
||||
...(cfg.env ? { env: cfg.env } : {}),
|
||||
...(cfg.cwd ? { cwd: cfg.cwd } : {}),
|
||||
}),
|
||||
});
|
||||
clients.push(client);
|
||||
for (const [toolName, tool] of Object.entries(await client.tools())) {
|
||||
tools[`mcp__${name}__${toolName}`] = tool;
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push({ server: name, message: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
tools,
|
||||
errors,
|
||||
close: async () => {
|
||||
await Promise.all(clients.map((c) => c.close().catch(() => {})));
|
||||
},
|
||||
};
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
import { tool, type LanguageModel } from 'ai';
|
||||
import { generateText } from 'ai';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type MemoryKind = 'fact' | 'decision' | 'gotcha' | 'command';
|
||||
|
||||
export type MemoryEntry = {
|
||||
id: string;
|
||||
kind: MemoryKind;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
/** Bumped on each recall so summarisation can keep what gets used. */
|
||||
hits: number;
|
||||
};
|
||||
|
||||
const MAX_ENTRIES = 300;
|
||||
const MAX_TEXT = 400;
|
||||
const BOOT_ENTRIES = 20;
|
||||
const SEARCH_HITS = 15;
|
||||
/** Summarise once the store passes this, so the boot block stays small. */
|
||||
const SUMMARISE_AT = 60;
|
||||
|
||||
const root = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'memory');
|
||||
|
||||
/** One file per project directory; the path is hashed because it is not filename-safe. */
|
||||
const fileFor = (cwd: string) => join(root(), `${createHash('sha256').update(cwd).digest('hex').slice(0, 16)}.json`);
|
||||
|
||||
const KIND_LABEL: Record<MemoryKind, string> = {
|
||||
fact: 'fact',
|
||||
decision: 'decision',
|
||||
gotcha: 'gotcha',
|
||||
command: 'command',
|
||||
};
|
||||
|
||||
/**
|
||||
* Durable per-project memory, separate from the session transcript.
|
||||
*
|
||||
* The transcript is destroyed by compaction and discarded when a session ends.
|
||||
* Anything worth knowing on the next run has to live here instead.
|
||||
*/
|
||||
export class Memory {
|
||||
private entries: MemoryEntry[] = [];
|
||||
private loaded = false;
|
||||
|
||||
constructor(
|
||||
private readonly cwd = process.cwd(),
|
||||
private readonly model?: LanguageModel,
|
||||
) {}
|
||||
|
||||
async load(): Promise<MemoryEntry[]> {
|
||||
if (this.loaded) return this.entries;
|
||||
this.loaded = true;
|
||||
const f = Bun.file(fileFor(this.cwd));
|
||||
if (await f.exists()) {
|
||||
try {
|
||||
const parsed: unknown = await f.json();
|
||||
if (Array.isArray(parsed)) this.entries = parsed.filter(isEntry);
|
||||
} catch {
|
||||
this.entries = [];
|
||||
}
|
||||
}
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
all(): MemoryEntry[] {
|
||||
return [...this.entries];
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
this.entries = this.entries.slice(-MAX_ENTRIES);
|
||||
await Bun.write(fileFor(this.cwd), JSON.stringify(this.entries, null, 2));
|
||||
}
|
||||
|
||||
async add(kind: MemoryKind, text: string): Promise<MemoryEntry | undefined> {
|
||||
await this.load();
|
||||
const clean = text.trim().slice(0, MAX_TEXT);
|
||||
if (!clean) throw new Error('memory text is empty');
|
||||
if (this.entries.some((e) => e.text === clean)) return undefined;
|
||||
|
||||
const entry: MemoryEntry = {
|
||||
id: Bun.randomUUIDv7(),
|
||||
kind,
|
||||
text: clean,
|
||||
createdAt: new Date().toISOString(),
|
||||
hits: 0,
|
||||
};
|
||||
this.entries.push(entry);
|
||||
await this.persist();
|
||||
return entry;
|
||||
}
|
||||
|
||||
async forget(idOrPrefix: string): Promise<number> {
|
||||
await this.load();
|
||||
const before = this.entries.length;
|
||||
this.entries = this.entries.filter((e) => !e.id.startsWith(idOrPrefix));
|
||||
if (this.entries.length !== before) await this.persist();
|
||||
return before - this.entries.length;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await this.load();
|
||||
this.entries = [];
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
/** Every term must appear. Matching entries get a hit, which protects them from summarisation. */
|
||||
async search(query: string): Promise<MemoryEntry[]> {
|
||||
await this.load();
|
||||
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (terms.length === 0) throw new Error('query is empty');
|
||||
|
||||
const found = this.entries.filter((e) => {
|
||||
const lower = e.text.toLowerCase();
|
||||
return terms.every((t) => lower.includes(t));
|
||||
});
|
||||
for (const e of found) e.hits += 1;
|
||||
if (found.length > 0) await this.persist();
|
||||
return found.slice(-SEARCH_HITS).reverse();
|
||||
}
|
||||
|
||||
/** The block injected at boot: most-used first, then most recent. */
|
||||
render(limit = BOOT_ENTRIES): string {
|
||||
if (this.entries.length === 0) return '';
|
||||
const ranked = [...this.entries]
|
||||
.sort((a, b) => b.hits - a.hits || b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, limit);
|
||||
return [
|
||||
'',
|
||||
'What you learned about this project in earlier sessions. Trust it, but verify anything',
|
||||
'that contradicts what you can see in the code now:',
|
||||
...ranked.map((e) => `- (${KIND_LABEL[e.kind]}) ${e.text}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
needsSummary(): boolean {
|
||||
return this.entries.length >= SUMMARISE_AT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses the store into fewer, denser entries using the model. Unused entries
|
||||
* are the ones that get merged away; anything recalled at least once is kept verbatim.
|
||||
*/
|
||||
async summarize(): Promise<{ before: number; after: number }> {
|
||||
await this.load();
|
||||
const before = this.entries.length;
|
||||
if (!this.model) throw new Error('no model available to summarize memory');
|
||||
if (before === 0) return { before, after: 0 };
|
||||
|
||||
const used = this.entries.filter((e) => e.hits > 0);
|
||||
const unused = this.entries.filter((e) => e.hits === 0);
|
||||
if (unused.length < 2) return { before, after: before };
|
||||
|
||||
const { text } = await generateText({
|
||||
model: this.model,
|
||||
system:
|
||||
'You are compacting an agent\'s notes about one codebase. Merge duplicates and near-duplicates, ' +
|
||||
'drop anything that is no longer useful or was only true of one past task, and keep the rest verbatim ' +
|
||||
'where you can. Output one note per line, each prefixed with its kind in brackets: ' +
|
||||
'[fact], [decision], [gotcha], or [command]. No preamble, no numbering, no blank lines.',
|
||||
prompt: unused.map((e) => `[${e.kind}] ${e.text}`).join('\n'),
|
||||
maxRetries: 2,
|
||||
});
|
||||
|
||||
const merged = text
|
||||
.split('\n')
|
||||
.map((line) => /^\s*\[(fact|decision|gotcha|command)\]\s*(.+?)\s*$/i.exec(line))
|
||||
.filter((m): m is RegExpExecArray => m !== null)
|
||||
.map((m) => ({
|
||||
id: Bun.randomUUIDv7(),
|
||||
kind: m[1]!.toLowerCase() as MemoryKind,
|
||||
text: m[2]!.slice(0, MAX_TEXT),
|
||||
createdAt: new Date().toISOString(),
|
||||
hits: 0,
|
||||
}));
|
||||
|
||||
// A model that returned nothing parseable must not wipe the store.
|
||||
if (merged.length === 0) return { before, after: before };
|
||||
|
||||
this.entries = [...used, ...merged];
|
||||
await this.persist();
|
||||
return { before, after: this.entries.length };
|
||||
}
|
||||
|
||||
tools() {
|
||||
return {
|
||||
remember: tool({
|
||||
description:
|
||||
'Record something about this project that will still be true next session: a decision and its reason, ' +
|
||||
'a command that works, a constraint, a trap you hit. Persisted across sessions and shown to you at start. ' +
|
||||
'Do not use it for narration or for anything specific to the current task only.',
|
||||
inputSchema: z.object({
|
||||
kind: z
|
||||
.enum(['fact', 'decision', 'gotcha', 'command'])
|
||||
.describe('fact: how it is. decision: what was chosen and why. gotcha: a trap. command: an invocation that works'),
|
||||
text: z.string().describe('One self-contained line, understandable with no other context'),
|
||||
}),
|
||||
execute: async ({ kind, text }) => {
|
||||
const entry = await this.add(kind, text);
|
||||
if (!entry) return `Already recorded: ${text.trim()}`;
|
||||
return `Remembered as ${entry.kind} (${this.entries.length} stored): ${entry.text}`;
|
||||
},
|
||||
}),
|
||||
|
||||
recall: tool({
|
||||
description:
|
||||
'Search what you recorded about this project in earlier sessions. Use it before investigating anything ' +
|
||||
'that might already be known, and when the user refers to past work.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Words that would appear in the note'),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
const found = await this.search(query);
|
||||
if (found.length === 0) return `Nothing recorded about "${query}".`;
|
||||
return found.map((e) => `(${e.kind}) ${e.text}`).join('\n');
|
||||
},
|
||||
}),
|
||||
|
||||
forget: tool({
|
||||
description:
|
||||
'Remove a memory that turned out to be wrong or is now obsolete. Search with recall first to get its text.',
|
||||
inputSchema: z.object({
|
||||
text: z.string().describe('Exact text of the memory to remove, or a distinctive part of it'),
|
||||
}),
|
||||
execute: async ({ text }) => {
|
||||
await this.load();
|
||||
const needle = text.trim().toLowerCase();
|
||||
const before = this.entries.length;
|
||||
this.entries = this.entries.filter((e) => !e.text.toLowerCase().includes(needle));
|
||||
const removed = before - this.entries.length;
|
||||
if (removed > 0) await this.persist();
|
||||
return removed > 0 ? `Forgot ${removed} memor${removed === 1 ? 'y' : 'ies'}.` : `No memory matches "${text}".`;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { fileFor as memoryFileFor, root as memoryDir, KIND_LABEL };
|
||||
|
||||
function isEntry(value: unknown): value is MemoryEntry {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v['id'] === 'string' &&
|
||||
typeof v['text'] === 'string' &&
|
||||
typeof v['createdAt'] === 'string' &&
|
||||
typeof v['hits'] === 'number' &&
|
||||
['fact', 'decision', 'gotcha', 'command'].includes(String(v['kind']))
|
||||
);
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type TodoStatus = 'pending' | 'in_progress' | 'done' | 'blocked';
|
||||
export type Todo = { content: string; status: TodoStatus; note?: string };
|
||||
|
||||
export type NotebookState = { todos: Todo[] };
|
||||
|
||||
const MARK: Record<TodoStatus, string> = {
|
||||
pending: '[ ]',
|
||||
in_progress: '[~]',
|
||||
done: '[x]',
|
||||
blocked: '[!]',
|
||||
};
|
||||
|
||||
const STATUSES = ['pending', 'in_progress', 'done', 'blocked'] as const;
|
||||
|
||||
const renderTodo = (t: Todo) => `${MARK[t.status]} ${t.content}${t.note?.trim() ? ` (${t.note.trim()})` : ''}`;
|
||||
|
||||
function isTodo(value: unknown): value is Todo {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return typeof v['content'] === 'string' && (STATUSES as readonly string[]).includes(String(v['status']));
|
||||
}
|
||||
|
||||
/**
|
||||
* The task list for the current session.
|
||||
*
|
||||
* Both `pruneMessages` and `/compact` destroy tool results and older turns, so a plan
|
||||
* recorded only in the transcript is lost exactly when a long task needs it. This is
|
||||
* re-rendered into the system prompt on every step instead, so it survives both.
|
||||
* Anything that should outlive the session belongs in Memory, not here.
|
||||
*/
|
||||
export class Notebook {
|
||||
private todos: Todo[] = [];
|
||||
|
||||
constructor(private readonly onChange?: (state: NotebookState) => void) {}
|
||||
|
||||
state(): NotebookState {
|
||||
return { todos: this.todos.map((t) => ({ ...t })) };
|
||||
}
|
||||
|
||||
restore(state: Partial<NotebookState> | undefined): void {
|
||||
if (Array.isArray(state?.todos)) this.todos = state.todos.filter(isTodo);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.todos = [];
|
||||
this.onChange?.(this.state());
|
||||
}
|
||||
|
||||
progress(): { done: number; total: number; blocked: number; current?: Todo } {
|
||||
const current = this.todos.find((t) => t.status === 'in_progress');
|
||||
return {
|
||||
done: this.todos.filter((t) => t.status === 'done').length,
|
||||
total: this.todos.length,
|
||||
blocked: this.todos.filter((t) => t.status === 'blocked').length,
|
||||
...(current ? { current } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
render(): string {
|
||||
if (this.todos.length === 0) return '';
|
||||
const { done, total, blocked } = this.progress();
|
||||
const header = `\nYour task list (${done}/${total} done${blocked > 0 ? `, ${blocked} blocked` : ''}). Keep it current with todo_write:`;
|
||||
return `${header}\n${this.todos.map(renderTodo).join('\n')}`;
|
||||
}
|
||||
|
||||
tools() {
|
||||
return {
|
||||
todo_write: tool({
|
||||
description:
|
||||
'Record or update your task list for a multi-step job. Send the whole list every time; it replaces the ' +
|
||||
'previous one. Exactly one task should be in_progress. Mark a task done the moment it is finished, not in ' +
|
||||
'a batch at the end. Use blocked with a note when something outside your control stops you. The list is ' +
|
||||
'shown to the user and survives context compaction, so it is where your plan lives. ' +
|
||||
'Skip it entirely for single-step work.',
|
||||
inputSchema: z.object({
|
||||
todos: z
|
||||
.array(
|
||||
z.object({
|
||||
content: z
|
||||
.string()
|
||||
.describe('One concrete action with a verifiable outcome, e.g. "add limit/offset to listUsers()"'),
|
||||
status: z.enum(STATUSES),
|
||||
note: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Required for blocked: what is blocking it. Otherwise a short finding worth keeping.'),
|
||||
}),
|
||||
)
|
||||
.describe('The complete list, in the order you will do them'),
|
||||
}),
|
||||
execute: async ({ todos }) => {
|
||||
this.todos = todos;
|
||||
this.onChange?.(this.state());
|
||||
|
||||
const active = todos.filter((t) => t.status === 'in_progress');
|
||||
const blocked = todos.filter((t) => t.status === 'blocked');
|
||||
const { done, total } = this.progress();
|
||||
|
||||
const warnings: string[] = [];
|
||||
if (active.length > 1) warnings.push(`${active.length} tasks are in_progress; keep it to one.`);
|
||||
if (active.length === 0 && done < total && blocked.length < total - done) {
|
||||
warnings.push('nothing is in_progress; mark what you are working on.');
|
||||
}
|
||||
for (const t of blocked) {
|
||||
if (!t.note?.trim()) warnings.push(`"${t.content}" is blocked with no note saying why.`);
|
||||
}
|
||||
|
||||
const lines = [`Task list updated: ${done}/${total} done.`, ...todos.map(renderTodo)];
|
||||
if (warnings.length > 0) lines.push(`Warning: ${warnings.join(' ')}`);
|
||||
return lines.join('\n');
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { MARK as TODO_MARK, STATUSES };
|
||||
@@ -0,0 +1,74 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import type { Plugin } from './plugins';
|
||||
|
||||
/**
|
||||
* Commands that destroy work irreversibly. Approval alone is a weak defence here:
|
||||
* a user holding `a` for a batch of edits will approve one of these without reading it,
|
||||
* so they are refused outright and the user has to run them by hand.
|
||||
*/
|
||||
const DESTRUCTIVE: { re: RegExp; why: string }[] = [
|
||||
{ re: /\brm\s+(-[a-zA-Z]*\s+)*-[a-zA-Z]*[rf]/, why: 'recursive or forced delete' },
|
||||
{ re: /\bgit\s+reset\s+--hard\b/, why: 'discards uncommitted work' },
|
||||
{ re: /\bgit\s+clean\s+-[a-zA-Z]*f/, why: 'deletes untracked files' },
|
||||
{ re: /\bgit\s+push\b.*(--force\b|--force-with-lease\b|\s-f\b)/, why: 'rewrites remote history' },
|
||||
{ re: /\bgit\s+branch\s+-D\b/, why: 'deletes a branch without a merge check' },
|
||||
{ re: /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b/i, why: 'destroys database data' },
|
||||
{ re: /\bmkfs(\.\w+)?\b|\bdd\s+[^|]*of=\/dev\//, why: 'writes to a raw device' },
|
||||
{ re: />\s*\/dev\/(sd|nvme|disk)/, why: 'writes to a raw device' },
|
||||
{ re: /\bchmod\s+(-[a-zA-Z]*\s+)*777\b/, why: 'makes files world-writable' },
|
||||
{ re: /\b(shutdown|reboot|halt)\b/, why: 'affects the whole machine' },
|
||||
{ re: /:\(\)\s*\{.*\}\s*;\s*:/, why: 'fork bomb' },
|
||||
{ re: /\bcurl\b[^|]*\|\s*(ba|z|k)?sh\b|\bwget\b[^|]*\|\s*(ba|z|k)?sh\b/, why: 'pipes a download straight into a shell' },
|
||||
];
|
||||
|
||||
export const guardPlugin: Plugin = {
|
||||
name: 'guard',
|
||||
description: 'refuses irreversible shell commands outright',
|
||||
appendix:
|
||||
'The guard plugin refuses irreversible shell commands (recursive deletes, hard resets, force pushes, ' +
|
||||
'DROP TABLE, piping downloads into a shell). If one is refused, do not work around it: tell the user ' +
|
||||
'what needs running and let them do it themselves.',
|
||||
beforeToolCall: ({ toolName, input }) => {
|
||||
if (toolName !== 'bash') return undefined;
|
||||
const command = String((input as { command?: unknown } | null)?.command ?? '');
|
||||
if (!command) return undefined;
|
||||
for (const { re, why } of DESTRUCTIVE) {
|
||||
if (re.test(command)) {
|
||||
return `refusing "${command.slice(0, 120)}" (${why}). Ask the user to run it themselves if it is really needed.`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export const bellPlugin: Plugin = {
|
||||
name: 'bell',
|
||||
description: 'rings the terminal bell when a turn ends',
|
||||
afterTurn: () => {
|
||||
process.stderr.write('\u0007');
|
||||
},
|
||||
};
|
||||
|
||||
export const timePlugin: Plugin = {
|
||||
name: 'time',
|
||||
description: 'adds a current_time tool',
|
||||
autoApprove: ['current_time'],
|
||||
tools: {
|
||||
current_time: tool({
|
||||
description: 'Current date and time in ISO 8601, with the local timezone. Use it when the date matters.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
const now = new Date();
|
||||
return `${now.toISOString()} (local: ${now.toString()})`;
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_PLUGINS: Plugin[] = [guardPlugin, bellPlugin, timePlugin];
|
||||
|
||||
/** Enabled unless the config turns them off. bell is opt-in; a bell per turn is intrusive. */
|
||||
export const DEFAULT_ENABLED = ['guard', 'time'];
|
||||
|
||||
export { DESTRUCTIVE };
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ToolSet } from 'ai';
|
||||
|
||||
export type ToolCallContext = {
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
/** Returning a string blocks the call; the string is handed to the model as the reason. */
|
||||
export type BeforeToolCall = (ctx: ToolCallContext) => string | undefined | Promise<string | undefined>;
|
||||
|
||||
export type Plugin = {
|
||||
name: string;
|
||||
description: string;
|
||||
/** Extra tools contributed by this plugin. */
|
||||
tools?: ToolSet;
|
||||
/** Tool names that should never prompt for approval. */
|
||||
autoApprove?: readonly string[];
|
||||
beforeToolCall?: BeforeToolCall;
|
||||
afterTurn?: () => void | Promise<void>;
|
||||
/** Text appended to the system prompt. */
|
||||
appendix?: string;
|
||||
};
|
||||
|
||||
export type PluginHost = {
|
||||
plugins: Plugin[];
|
||||
tools: ToolSet;
|
||||
autoApprove: string[];
|
||||
appendix: string;
|
||||
/** Runs every beforeToolCall hook; the first block wins. */
|
||||
guard: BeforeToolCall;
|
||||
afterTurn: () => Promise<void>;
|
||||
errors: { plugin: string; message: string }[];
|
||||
};
|
||||
|
||||
export function createHost(plugins: Plugin[], errors: PluginHost['errors'] = []): PluginHost {
|
||||
const tools: ToolSet = {};
|
||||
const autoApprove: string[] = [];
|
||||
const appendices: string[] = [];
|
||||
|
||||
for (const p of plugins) {
|
||||
for (const [name, t] of Object.entries(p.tools ?? {})) tools[name] = t;
|
||||
autoApprove.push(...(p.autoApprove ?? []));
|
||||
if (p.appendix) appendices.push(p.appendix);
|
||||
}
|
||||
|
||||
return {
|
||||
plugins,
|
||||
tools,
|
||||
autoApprove,
|
||||
appendix: appendices.length > 0 ? `\n${appendices.join('\n')}` : '',
|
||||
errors,
|
||||
guard: async (ctx) => {
|
||||
for (const p of plugins) {
|
||||
if (!p.beforeToolCall) continue;
|
||||
try {
|
||||
const blocked = await p.beforeToolCall(ctx);
|
||||
if (blocked) return `Blocked by the ${p.name} plugin: ${blocked}`;
|
||||
} catch (e) {
|
||||
// A broken hook must not take the agent down, but it must not silently
|
||||
// allow the call either: treat a throwing guard as a block.
|
||||
return `The ${p.name} plugin failed while checking this call: ${e instanceof Error ? e.message : String(e)}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
afterTurn: async () => {
|
||||
for (const p of plugins) {
|
||||
try {
|
||||
await p.afterTurn?.();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export type Rate = { inputPerMTok: number; outputPerMTok: number };
|
||||
|
||||
/**
|
||||
* USD per million tokens. Prefix match on the model id, longest first, so
|
||||
* `claude-sonnet-4-5-20250929` resolves via `claude-sonnet-4-5`. Published rates
|
||||
* drift, so this is a best-effort estimate rather than a billing source.
|
||||
*/
|
||||
const RATES: Record<string, Rate> = {
|
||||
'claude-opus-4': { inputPerMTok: 15, outputPerMTok: 75 },
|
||||
'claude-sonnet-4': { inputPerMTok: 3, outputPerMTok: 15 },
|
||||
'claude-haiku-4': { inputPerMTok: 1, outputPerMTok: 5 },
|
||||
'claude-3-5-haiku': { inputPerMTok: 0.8, outputPerMTok: 4 },
|
||||
'gpt-5-mini': { inputPerMTok: 0.25, outputPerMTok: 2 },
|
||||
'gpt-5-nano': { inputPerMTok: 0.05, outputPerMTok: 0.4 },
|
||||
'gpt-5': { inputPerMTok: 1.25, outputPerMTok: 10 },
|
||||
'gpt-4o-mini': { inputPerMTok: 0.15, outputPerMTok: 0.6 },
|
||||
'gpt-4o': { inputPerMTok: 2.5, outputPerMTok: 10 },
|
||||
'o4-mini': { inputPerMTok: 1.1, outputPerMTok: 4.4 },
|
||||
'deepseek-chat': { inputPerMTok: 0.27, outputPerMTok: 1.1 },
|
||||
'deepseek-reasoner': { inputPerMTok: 0.55, outputPerMTok: 2.19 },
|
||||
'grok-4': { inputPerMTok: 3, outputPerMTok: 15 },
|
||||
};
|
||||
|
||||
/** Strips a provider prefix such as `anthropic/` that OpenRouter-style ids carry. */
|
||||
const bare = (modelId: string) => modelId.toLowerCase().split('/').at(-1) ?? modelId.toLowerCase();
|
||||
|
||||
export function rateFor(modelId: string): Rate | undefined {
|
||||
const id = bare(modelId);
|
||||
const key = Object.keys(RATES)
|
||||
.filter((k) => id.startsWith(k))
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
return key ? RATES[key] : undefined;
|
||||
}
|
||||
|
||||
export function costOf(modelId: string, inputTokens: number, outputTokens: number): number | undefined {
|
||||
const rate = rateFor(modelId);
|
||||
if (!rate) return undefined;
|
||||
return (inputTokens / 1_000_000) * rate.inputPerMTok + (outputTokens / 1_000_000) * rate.outputPerMTok;
|
||||
}
|
||||
|
||||
export function formatUsd(amount: number): string {
|
||||
if (amount === 0) return '$0.00';
|
||||
if (amount < 0.01) return `$${amount.toFixed(4)}`;
|
||||
return `$${amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** One-line token and cost summary, omitting the cost when the model is unpriced. */
|
||||
export function usageLine(modelId: string, inputTokens: number, outputTokens: number): string {
|
||||
const tokens = `${inputTokens} in / ${outputTokens} out tokens`;
|
||||
const cost = costOf(modelId, inputTokens, outputTokens);
|
||||
return cost === undefined ? `${tokens} (${modelId} is unpriced)` : `${tokens} - ${formatUsd(cost)}`;
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import { formatInstructions, type Instructions } from './instructions';
|
||||
|
||||
export type PromptParts = {
|
||||
cwd: string;
|
||||
instructions?: Instructions;
|
||||
/** Session task list from the Notebook. */
|
||||
notebook?: string;
|
||||
/** Durable project memory. */
|
||||
memory?: string;
|
||||
/** Skill catalogue: names and descriptions only. */
|
||||
skills?: string;
|
||||
/** Behaviour appendix from the selected agent variant. */
|
||||
agent?: string;
|
||||
/** Appendices contributed by plugins. */
|
||||
plugins?: string;
|
||||
/** Tool names actually offered this turn, so the prompt cannot describe a tool that is absent. */
|
||||
availableTools?: readonly string[];
|
||||
/** True when the ask tool has somewhere to send a question. */
|
||||
canAsk?: boolean;
|
||||
};
|
||||
|
||||
type ToolDoc = { name: string; line: string };
|
||||
|
||||
/**
|
||||
* Guidance per tool, beyond the schema description the model already receives.
|
||||
*
|
||||
* The schema says what a tool takes; this says when to reach for it and what goes
|
||||
* wrong. Only tools actually offered are described, because a prompt that mentions
|
||||
* a withheld tool teaches the model to attempt calls that cannot succeed.
|
||||
*/
|
||||
const TOOL_DOCS: ToolDoc[] = [
|
||||
{ name: 'read_file', line: 'read before you edit. Never describe code you have not opened.' },
|
||||
{
|
||||
name: 'glob',
|
||||
line: 'find files by pattern. Skips binaries and .gitignore; pass includeIgnored to look anyway.',
|
||||
},
|
||||
{
|
||||
name: 'grep',
|
||||
line: 'search contents. Prefer it over reading many files; scope with include to keep results small.',
|
||||
},
|
||||
{
|
||||
name: 'edit_file',
|
||||
line: 'oldString must match byte-for-byte including indentation, and be unique. Include surrounding lines to disambiguate. Prefer several small edits over one large rewrite.',
|
||||
},
|
||||
{ name: 'write_file', line: 'new files and full rewrites only. Reach for edit_file on anything that exists.' },
|
||||
{
|
||||
name: 'bash',
|
||||
line: 'builds, tests, git, package managers. Output streams live. Long-running commands are fine; interactive ones are not.',
|
||||
},
|
||||
{
|
||||
name: 'task',
|
||||
line: 'delegate a read-only search to a subagent. Its prompt must be self-contained; it sees none of this conversation. Worth it when a search would span many files, wasteful for a single grep.',
|
||||
},
|
||||
{
|
||||
name: 'ask',
|
||||
line: 'stop and ask the user. Cheaper than a wrong guess when a request has two readings that lead to different work.',
|
||||
},
|
||||
{
|
||||
name: 'todo_write',
|
||||
line: 'your plan for a multi-step job. Send the whole list each time. One task in_progress. Mark done immediately, not in a batch.',
|
||||
},
|
||||
{ name: 'remember', line: 'record something still true next session: a decision, a working command, a trap.' },
|
||||
{ name: 'recall', line: 'search what you recorded before. Try it before investigating something possibly known.' },
|
||||
{ name: 'forget', line: 'remove a memory that turned out wrong.' },
|
||||
{ name: 'skill', line: 'load detailed instructions for a kind of task. Call it before starting, not after.' },
|
||||
{ name: 'current_time', line: 'the current date and time, when it matters.' },
|
||||
];
|
||||
|
||||
function renderTools(available: readonly string[]): string {
|
||||
const known = TOOL_DOCS.filter((d) => available.includes(d.name));
|
||||
const extra = available.filter((name) => !TOOL_DOCS.some((d) => d.name === name)).sort();
|
||||
|
||||
const lines = known.map((d) => `- ${d.name}: ${d.line}`);
|
||||
|
||||
const mcp = extra.filter((n) => n.startsWith('mcp__'));
|
||||
const other = extra.filter((n) => !n.startsWith('mcp__'));
|
||||
if (mcp.length > 0) {
|
||||
lines.push(
|
||||
`- ${mcp.join(', ')}: from MCP servers, named mcp__<server>__<tool>. Each needs approval; read its own description before calling.`,
|
||||
);
|
||||
}
|
||||
for (const name of other) lines.push(`- ${name}: see its own description.`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function systemPrompt(parts: PromptParts): string {
|
||||
const {
|
||||
cwd,
|
||||
instructions = [],
|
||||
notebook = '',
|
||||
memory = '',
|
||||
skills = '',
|
||||
agent = '',
|
||||
plugins = '',
|
||||
availableTools,
|
||||
canAsk = false,
|
||||
} = parts;
|
||||
|
||||
const toolNames = availableTools ?? TOOL_DOCS.map((d) => d.name);
|
||||
const canEdit = toolNames.includes('edit_file') || toolNames.includes('write_file');
|
||||
const canRun = toolNames.includes('bash');
|
||||
|
||||
const workflow = [
|
||||
'- Read before you write. Ground every claim about the code in something you actually opened.',
|
||||
'- Make the smallest change that solves the task. A bugfix diff contains only the bug.',
|
||||
'- Match the existing style, libraries, and conventions. Sample a neighbouring file before inventing a pattern.',
|
||||
canEdit
|
||||
? '- write_file, edit_file, and bash need the user to approve each call. If one is denied, stop and ask what to do instead of working around it.'
|
||||
: '- You have no tools that change anything this turn. Investigate and report; do not describe edits as if you had made them.',
|
||||
canRun
|
||||
? "- After changing code, verify it: run the project's build or tests. \"Should work\" is not verification."
|
||||
: '- You cannot run commands this turn, so say what should be run to verify rather than claiming it passes.',
|
||||
'- When something fails twice, stop and re-read the error literally. Check that the code you think is running is the code that is running.',
|
||||
canAsk
|
||||
? '- Ask rather than guess when two readings of the request lead to different work. Decide small things yourself and say what you assumed.'
|
||||
: '- No one can answer a question this run. Decide yourself and state the assumption plainly.',
|
||||
].join('\n');
|
||||
|
||||
return `You are Shiro Neko, a coding agent working in the user's terminal.
|
||||
|
||||
Environment
|
||||
- Workspace root: ${cwd}
|
||||
- Platform: ${process.platform}
|
||||
- Paths are resolved inside the workspace. Anything outside it is refused.
|
||||
|
||||
Tools available to you now
|
||||
${renderTools(toolNames)}
|
||||
|
||||
How to work
|
||||
${workflow}
|
||||
|
||||
How to reply
|
||||
- Lead with the outcome. The user wants to know what happened, not what you are about to do.
|
||||
- No preamble, no restating the task, no summary of your own summary.
|
||||
- Markdown is rendered: use fenced code blocks for code, backticks for identifiers and paths.
|
||||
- Report failures with their actual output. Never imply a command passed when you did not run it.
|
||||
${formatInstructions(instructions, cwd)}${memory}${skills}${agent}${plugins}${notebook}`;
|
||||
}
|
||||
|
||||
export { TOOL_DOCS, renderTools };
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { ProviderName } from './config';
|
||||
|
||||
export type ProviderPreset = {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Which wire protocol to speak. */
|
||||
kind: ProviderName;
|
||||
baseURL: string;
|
||||
/** Env var checked before asking for a key. */
|
||||
envKey?: string;
|
||||
/** Servers that ignore auth, e.g. a local Ollama. */
|
||||
keyless?: boolean;
|
||||
keyHint?: string;
|
||||
fallbackModels?: string[];
|
||||
};
|
||||
|
||||
export const PRESETS: ProviderPreset[] = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
kind: 'anthropic',
|
||||
baseURL: 'https://api.anthropic.com/v1',
|
||||
envKey: 'ANTHROPIC_API_KEY',
|
||||
keyHint: 'sk-ant-...',
|
||||
fallbackModels: ['claude-sonnet-4-5', 'claude-opus-4-1', 'claude-haiku-4-5'],
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
label: 'OpenAI',
|
||||
kind: 'openai',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
keyHint: 'sk-...',
|
||||
fallbackModels: ['gpt-5', 'gpt-5-mini', 'o4-mini'],
|
||||
},
|
||||
{
|
||||
id: 'openrouter',
|
||||
label: 'OpenRouter (many models, one key)',
|
||||
kind: 'openai',
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
envKey: 'OPENROUTER_API_KEY',
|
||||
keyHint: 'sk-or-...',
|
||||
},
|
||||
{
|
||||
id: 'groq',
|
||||
label: 'Groq',
|
||||
kind: 'openai',
|
||||
baseURL: 'https://api.groq.com/openai/v1',
|
||||
envKey: 'GROQ_API_KEY',
|
||||
keyHint: 'gsk_...',
|
||||
},
|
||||
{
|
||||
id: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
kind: 'openai',
|
||||
baseURL: 'https://api.deepseek.com/v1',
|
||||
envKey: 'DEEPSEEK_API_KEY',
|
||||
keyHint: 'sk-...',
|
||||
},
|
||||
{
|
||||
id: 'xai',
|
||||
label: 'xAI (Grok)',
|
||||
kind: 'openai',
|
||||
baseURL: 'https://api.x.ai/v1',
|
||||
envKey: 'XAI_API_KEY',
|
||||
keyHint: 'xai-...',
|
||||
},
|
||||
{
|
||||
id: 'ollama',
|
||||
label: 'Ollama (local)',
|
||||
kind: 'openai',
|
||||
baseURL: 'http://localhost:11434/v1',
|
||||
keyless: true,
|
||||
},
|
||||
{
|
||||
id: 'lmstudio',
|
||||
label: 'LM Studio (local)',
|
||||
kind: 'openai',
|
||||
baseURL: 'http://localhost:1234/v1',
|
||||
keyless: true,
|
||||
},
|
||||
{
|
||||
id: 'custom-openai',
|
||||
label: 'Custom OpenAI-compatible endpoint',
|
||||
kind: 'openai',
|
||||
baseURL: '',
|
||||
},
|
||||
{
|
||||
id: 'custom-anthropic',
|
||||
label: 'Custom Anthropic-compatible endpoint',
|
||||
kind: 'anthropic',
|
||||
baseURL: '',
|
||||
},
|
||||
];
|
||||
|
||||
export const presetById = (id: string) => PRESETS.find((p) => p.id === id);
|
||||
|
||||
export type ModelListResult = { models: string[]; source: 'api' | 'fallback'; warning?: string };
|
||||
|
||||
type ModelsResponse = { data?: unknown };
|
||||
|
||||
/**
|
||||
* Both OpenAI- and Anthropic-compatible servers expose `GET /v1/models` with a
|
||||
* `data[].id` shape, only the auth header differs. A server that does not
|
||||
* implement it is not fatal: the caller can still type a model id by hand.
|
||||
*/
|
||||
export async function fetchModels(
|
||||
preset: Pick<ProviderPreset, 'kind' | 'baseURL' | 'fallbackModels'>,
|
||||
apiKey: string,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<ModelListResult> {
|
||||
const url = `${preset.baseURL.replace(/\/+$/, '')}/models`;
|
||||
const headers: Record<string, string> =
|
||||
preset.kind === 'anthropic'
|
||||
? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }
|
||||
: { authorization: `Bearer ${apiKey}` };
|
||||
|
||||
const fallback = (warning: string): ModelListResult => ({
|
||||
models: preset.fallbackModels ?? [],
|
||||
source: 'fallback',
|
||||
warning,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (!res.ok) {
|
||||
const body = (await res.text()).slice(0, 200);
|
||||
return fallback(`${url} returned ${res.status}. ${body}`.trim());
|
||||
}
|
||||
const json = (await res.json()) as ModelsResponse;
|
||||
const models = Array.isArray(json.data)
|
||||
? json.data
|
||||
.map((m) => (m && typeof m === 'object' ? (m as { id?: unknown }).id : undefined))
|
||||
.filter((id): id is string => typeof id === 'string')
|
||||
: [];
|
||||
if (models.length === 0) return fallback(`${url} listed no models.`);
|
||||
return { models: models.sort(), source: 'api' };
|
||||
} catch (e) {
|
||||
return fallback(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { pruneMessages, type ModelMessage } from 'ai';
|
||||
|
||||
type Part = { type: string; providerOptions?: Record<string, Record<string, unknown>> };
|
||||
|
||||
/** Parts the OpenAI responses API refuses to accept without their reasoning item. */
|
||||
const DEPENDENT = new Set(['text', 'tool-call']);
|
||||
|
||||
function itemId(part: Part): string | undefined {
|
||||
for (const options of Object.values(part.providerOptions ?? {})) {
|
||||
const id = options['itemId'];
|
||||
if (typeof id === 'string') return id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const partsOf = (message: ModelMessage): Part[] =>
|
||||
message.role === 'assistant' && Array.isArray(message.content) ? (message.content as Part[]) : [];
|
||||
|
||||
/**
|
||||
* Drops assistant parts left orphaned by reasoning removal.
|
||||
*
|
||||
* The OpenAI responses API treats a `message` item as a dependent of the `reasoning`
|
||||
* item from the same response: send the message without its reasoning and the request
|
||||
* is rejected with 400 "was provided without its required 'reasoning' item".
|
||||
* `pruneMessages({ reasoning: 'all' })` strips the reasoning and keeps the message,
|
||||
* producing exactly that request.
|
||||
*
|
||||
* The two carry different item ids, so they cannot be matched by id. What links them
|
||||
* is the assistant message they arrived in: one message is one response, and its
|
||||
* reasoning item covers every other item in it.
|
||||
*
|
||||
* Reasoning only disappears from turns pruning is already discarding, so dropping the
|
||||
* orphaned text costs nothing pruning was not already spending.
|
||||
*/
|
||||
export function dropOrphanedItems(before: ModelMessage[], after: ModelMessage[]): ModelMessage[] {
|
||||
const survivingReasoning = new Set<string>();
|
||||
for (const message of after) {
|
||||
for (const part of partsOf(message)) {
|
||||
if (part.type !== 'reasoning') continue;
|
||||
const id = itemId(part);
|
||||
if (id) survivingReasoning.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const orphaned = new Set<string>();
|
||||
for (const message of before) {
|
||||
const parts = partsOf(message);
|
||||
const reasoning = parts.filter((p) => p.type === 'reasoning').map(itemId);
|
||||
if (reasoning.length === 0) continue;
|
||||
if (reasoning.some((id) => id !== undefined && survivingReasoning.has(id))) continue;
|
||||
|
||||
for (const part of parts) {
|
||||
if (!DEPENDENT.has(part.type)) continue;
|
||||
const id = itemId(part);
|
||||
if (id) orphaned.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphaned.size === 0) return after;
|
||||
|
||||
const cleaned: ModelMessage[] = [];
|
||||
for (const message of after) {
|
||||
const parts = partsOf(message);
|
||||
if (parts.length === 0) {
|
||||
cleaned.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const kept = parts.filter((part) => {
|
||||
const id = itemId(part);
|
||||
return id === undefined || !orphaned.has(id);
|
||||
});
|
||||
|
||||
if (kept.length > 0) cleaned.push({ ...message, content: kept } as ModelMessage);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export type PruneOptions = Parameters<typeof pruneMessages>[0];
|
||||
|
||||
/** pruneMessages, then repair the provider-item dependencies it breaks. */
|
||||
export function prunePreservingItems(options: PruneOptions): ModelMessage[] {
|
||||
const pruned = pruneMessages(options);
|
||||
return dropOrphanedItems(options.messages, pruned);
|
||||
}
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
isStepCount,
|
||||
generateText,
|
||||
streamText,
|
||||
type LanguageModel,
|
||||
type ModelMessage,
|
||||
type ToolApprovalResponse,
|
||||
type ToolSet,
|
||||
} from 'ai';
|
||||
import { DEFAULT_VARIANT, sdkReasoning, renderAgent, type AgentVariant } from './agents';
|
||||
import { createAskTool, type AskFn } from './ask';
|
||||
import type { Instructions } from './instructions';
|
||||
import type { Memory } from './memory';
|
||||
import { Notebook, type NotebookState } from './notebook';
|
||||
import type { PluginHost } from './plugins';
|
||||
import { systemPrompt } from './prompt';
|
||||
import { prunePreservingItems } from './prune';
|
||||
import { createSkillTool, renderSkills, type Skill } from './skills';
|
||||
import { MUTATING_TOOLS, onBashOutput, tools as builtinTools } from './tools';
|
||||
|
||||
export type ApprovalRequest = {
|
||||
approvalId: string;
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
};
|
||||
|
||||
/** 'once' runs this call only; 'always' whitelists the tool for the rest of the session. */
|
||||
export type ApprovalDecision = 'once' | 'always' | 'deny';
|
||||
|
||||
export type AgentEvent =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'reasoning'; text: string }
|
||||
| { type: 'tool-call'; id: string; name: string; input: unknown }
|
||||
| { type: 'tool-output'; id: string; chunk: string }
|
||||
| { type: 'tool-result'; id: string; name: string; output: unknown }
|
||||
| { type: 'tool-error'; id: string; name: string; error: unknown }
|
||||
| { type: 'tool-denied'; name: string }
|
||||
| { type: 'compacted'; before: number; after: number }
|
||||
| { type: 'notice'; text: string }
|
||||
| { type: 'error'; error: unknown }
|
||||
| { type: 'done'; inputTokens?: number; outputTokens?: number };
|
||||
|
||||
export type SessionOptions = {
|
||||
model: LanguageModel;
|
||||
askApproval: (req: ApprovalRequest) => Promise<ApprovalDecision>;
|
||||
yolo?: boolean;
|
||||
cwd?: string;
|
||||
maxSteps?: number;
|
||||
/** MCP and subagent tools merged on top of the built-ins. */
|
||||
extraTools?: ToolSet;
|
||||
/** Tool names that never prompt, e.g. the read-only subagent tool. */
|
||||
autoApprove?: readonly string[];
|
||||
/** Prune the history once the estimated token count crosses this. */
|
||||
compactThreshold?: number;
|
||||
/** Retries per model call for transient failures. */
|
||||
maxRetries?: number;
|
||||
/** AGENTS.md-style files appended to the system prompt. */
|
||||
instructions?: Instructions;
|
||||
/** Task list restored from a resumed session. */
|
||||
notebook?: NotebookState;
|
||||
/** Thinking level, tool restrictions, and behaviour appendix. */
|
||||
agent?: AgentVariant;
|
||||
skills?: Skill[];
|
||||
memory?: Memory;
|
||||
plugins?: PluginHost;
|
||||
/** Where an `ask` tool call goes. Omit in headless runs. */
|
||||
ask?: AskFn;
|
||||
messages?: ModelMessage[];
|
||||
onChange?: (messages: ModelMessage[]) => void;
|
||||
/** Live stdout/stderr from bash, for a UI that wants progress. */
|
||||
onToolOutput?: (id: string, chunk: string) => void;
|
||||
onNotebookChange?: (state: NotebookState) => void;
|
||||
};
|
||||
|
||||
const estimateTokens = (messages: ModelMessage[]) => Math.round(JSON.stringify(messages).length / 4);
|
||||
|
||||
export class Session {
|
||||
readonly messages: ModelMessage[];
|
||||
readonly tools: ToolSet;
|
||||
readonly notebook: Notebook;
|
||||
inputTokens = 0;
|
||||
outputTokens = 0;
|
||||
private model: LanguageModel;
|
||||
private variant: AgentVariant;
|
||||
private readonly alwaysAllow = new Set<string>();
|
||||
private controller: AbortController | undefined;
|
||||
|
||||
constructor(private readonly opts: SessionOptions) {
|
||||
this.messages = opts.messages ?? [];
|
||||
this.notebook = new Notebook(opts.onNotebookChange);
|
||||
this.notebook.restore(opts.notebook);
|
||||
this.model = opts.model;
|
||||
this.variant = opts.agent ?? DEFAULT_VARIANT;
|
||||
|
||||
const sessionTools = {
|
||||
...this.notebook.tools(),
|
||||
...(opts.memory ? opts.memory.tools() : {}),
|
||||
...(opts.skills && opts.skills.length > 0 ? { skill: createSkillTool(opts.skills) } : {}),
|
||||
...(opts.ask ? { ask: createAskTool(opts.ask) } : {}),
|
||||
};
|
||||
this.tools = { ...builtinTools, ...sessionTools, ...(opts.plugins?.tools ?? {}), ...(opts.extraTools ?? {}) };
|
||||
|
||||
for (const name of [
|
||||
...(opts.autoApprove ?? []),
|
||||
...(opts.plugins?.autoApprove ?? []),
|
||||
...Object.keys(sessionTools),
|
||||
]) {
|
||||
this.alwaysAllow.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
setModel(model: LanguageModel): void {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
setAgent(variant: AgentVariant): void {
|
||||
this.variant = variant;
|
||||
}
|
||||
|
||||
agent(): AgentVariant {
|
||||
return this.variant;
|
||||
}
|
||||
|
||||
/** Tool names offered this turn; a read-only variant hides the rest. */
|
||||
activeTools(): string[] {
|
||||
const all = Object.keys(this.tools);
|
||||
if (!this.variant.allowTools) return all;
|
||||
return all.filter((name) => this.variant.allowTools!.includes(name));
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.messages.length = 0;
|
||||
this.inputTokens = 0;
|
||||
this.outputTokens = 0;
|
||||
this.notebook.clear();
|
||||
this.opts.onChange?.(this.messages);
|
||||
}
|
||||
|
||||
replace(messages: ModelMessage[]): void {
|
||||
this.messages.length = 0;
|
||||
this.messages.push(...messages);
|
||||
this.opts.onChange?.(this.messages);
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.controller?.abort();
|
||||
}
|
||||
|
||||
estimatedTokens(): number {
|
||||
return estimateTokens(this.messages);
|
||||
}
|
||||
|
||||
private systemFor(): string {
|
||||
return systemPrompt({
|
||||
cwd: this.opts.cwd ?? process.cwd(),
|
||||
instructions: this.opts.instructions ?? [],
|
||||
notebook: this.notebook.render(),
|
||||
memory: this.opts.memory?.render() ?? '',
|
||||
skills: renderSkills(this.opts.skills ?? []),
|
||||
agent: renderAgent(this.variant),
|
||||
plugins: this.opts.plugins?.appendix ?? '',
|
||||
availableTools: this.activeTools(),
|
||||
canAsk: this.opts.ask !== undefined && this.activeTools().includes('ask'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Tools that mutate the workspace, plus every externally provided MCP tool. */
|
||||
private needsApproval(name: string): boolean {
|
||||
return (MUTATING_TOOLS as readonly string[]).includes(name) || name.startsWith('mcp__');
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval decisions, evaluated per call by the SDK.
|
||||
*
|
||||
* A plugin guard denies outright and is checked before anything else, so `--yolo`
|
||||
* cannot bypass it. Only after the guard passes does yolo or the mutating-tool
|
||||
* rule decide whether the user is asked.
|
||||
*/
|
||||
private toolApproval(notices: string[]) {
|
||||
return async ({ toolCall }: { toolCall: { toolName: string; input: unknown } }) => {
|
||||
const blocked = await this.opts.plugins?.guard({
|
||||
toolName: toolCall.toolName,
|
||||
input: toolCall.input,
|
||||
cwd: this.opts.cwd ?? process.cwd(),
|
||||
});
|
||||
if (blocked) {
|
||||
notices.push(blocked);
|
||||
return { type: 'denied' as const, reason: blocked };
|
||||
}
|
||||
if (this.opts.yolo) return undefined;
|
||||
if (!this.needsApproval(toolCall.toolName)) return undefined;
|
||||
if (this.alwaysAllow.has(toolCall.toolName)) return undefined;
|
||||
return 'user-approval' as const;
|
||||
};
|
||||
}
|
||||
|
||||
/** Replaces the history with a model-written summary. Backs the /compact command. */
|
||||
async summarize(): Promise<{ before: number; after: number }> {
|
||||
const before = this.messages.length;
|
||||
if (before === 0) return { before, after: 0 };
|
||||
|
||||
const { text } = await generateText({
|
||||
model: this.model,
|
||||
system:
|
||||
'Summarize this coding session for use as the sole context of a fresh session. ' +
|
||||
'Keep: the user goal, files touched with paths, decisions made, commands run and their outcome, ' +
|
||||
'and what remains to be done. Drop pleasantries and full file contents. Write it as notes, not prose.',
|
||||
messages: this.messages,
|
||||
maxRetries: this.opts.maxRetries ?? 3,
|
||||
});
|
||||
|
||||
this.messages.length = 0;
|
||||
this.messages.push({ role: 'user', content: `Summary of the session so far:\n\n${text}` });
|
||||
this.opts.onChange?.(this.messages);
|
||||
return { before, after: this.messages.length };
|
||||
}
|
||||
|
||||
async *send(userText: string): AsyncGenerator<AgentEvent> {
|
||||
this.messages.push({ role: 'user', content: userText });
|
||||
this.opts.onChange?.(this.messages);
|
||||
this.controller = new AbortController();
|
||||
const signal = this.controller.signal;
|
||||
const threshold = this.opts.compactThreshold ?? 120_000;
|
||||
|
||||
const outputs: Extract<AgentEvent, { type: 'tool-output' }>[] = [];
|
||||
onBashOutput(({ toolCallId, chunk }) => {
|
||||
outputs.push({ type: 'tool-output', id: toolCallId, chunk });
|
||||
this.opts.onToolOutput?.(toolCallId, chunk);
|
||||
});
|
||||
|
||||
try {
|
||||
yield* this.run(signal, threshold, outputs);
|
||||
} finally {
|
||||
onBashOutput(undefined);
|
||||
await this.opts.plugins?.afterTurn();
|
||||
}
|
||||
}
|
||||
|
||||
private async *run(
|
||||
signal: AbortSignal,
|
||||
threshold: number,
|
||||
outputs: Extract<AgentEvent, { type: 'tool-output' }>[],
|
||||
): AsyncGenerator<AgentEvent> {
|
||||
// Each iteration is one model run. A run ends either finished, or suspended
|
||||
// on tool approvals, in which case we collect decisions and run again.
|
||||
while (true) {
|
||||
const pending: ApprovalRequest[] = [];
|
||||
const compactions: Extract<AgentEvent, { type: 'compacted' }>[] = [];
|
||||
const guardNotices: string[] = [];
|
||||
let sawError = false;
|
||||
|
||||
const result = streamText({
|
||||
model: this.model,
|
||||
system: this.systemFor(),
|
||||
messages: this.messages,
|
||||
tools: this.tools,
|
||||
activeTools: this.activeTools(),
|
||||
reasoning: sdkReasoning(this.variant.thinking),
|
||||
toolApproval: this.toolApproval(guardNotices),
|
||||
stopWhen: isStepCount(this.variant.maxSteps ?? this.opts.maxSteps ?? 50),
|
||||
maxRetries: this.opts.maxRetries ?? 3,
|
||||
abortSignal: signal,
|
||||
prepareStep: ({ messages }) => {
|
||||
// Rebuilt every step: a todo_write earlier in this same run must be
|
||||
// visible to the steps that follow it, not only to the next turn.
|
||||
const instructions = this.systemFor();
|
||||
if (estimateTokens(messages) <= threshold) return { instructions };
|
||||
const pruned = prunePreservingItems({
|
||||
messages,
|
||||
reasoning: 'all',
|
||||
toolCalls: 'before-last-3-messages',
|
||||
emptyMessages: 'remove',
|
||||
});
|
||||
// prepareStep cannot yield, so queue the notice and drain it in the loop.
|
||||
compactions.push({ type: 'compacted', before: messages.length, after: pruned.length });
|
||||
return { instructions, messages: pruned };
|
||||
},
|
||||
});
|
||||
|
||||
// Every promise-shaped accessor settles independently of the stream. Any one
|
||||
// left without a rejection sink surfaces as an unhandled rejection on abort
|
||||
// or API failure, which scribbles over the Ink render.
|
||||
const sink = () => {};
|
||||
void result.responseMessages.then(undefined, sink);
|
||||
void result.usage.then(undefined, sink);
|
||||
void result.steps.then(undefined, sink);
|
||||
void result.finalStep.then(undefined, sink);
|
||||
void result.text.then(undefined, sink);
|
||||
void result.finishReason.then(undefined, sink);
|
||||
|
||||
try {
|
||||
for await (const part of result.stream) {
|
||||
while (compactions.length > 0) yield compactions.shift()!;
|
||||
while (outputs.length > 0) yield outputs.shift()!;
|
||||
while (guardNotices.length > 0) yield { type: 'notice', text: guardNotices.shift()! };
|
||||
switch (part.type) {
|
||||
case 'text-delta':
|
||||
yield { type: 'text', text: part.text };
|
||||
break;
|
||||
case 'reasoning-delta':
|
||||
yield { type: 'reasoning', text: part.text };
|
||||
break;
|
||||
case 'tool-call':
|
||||
yield { type: 'tool-call', id: part.toolCallId, name: part.toolName, input: part.input };
|
||||
break;
|
||||
case 'tool-result':
|
||||
yield { type: 'tool-result', id: part.toolCallId, name: part.toolName, output: part.output };
|
||||
break;
|
||||
case 'tool-error':
|
||||
yield { type: 'tool-error', id: part.toolCallId, name: part.toolName, error: part.error };
|
||||
break;
|
||||
case 'tool-approval-request':
|
||||
// A guard denial is answered by the SDK itself and arrives flagged
|
||||
// automatic; queueing it would prompt the user for a settled call.
|
||||
if (part.isAutomatic) break;
|
||||
pending.push({
|
||||
approvalId: part.approvalId,
|
||||
toolName: part.toolCall.toolName,
|
||||
input: part.toolCall.input,
|
||||
});
|
||||
break;
|
||||
case 'tool-approval-response':
|
||||
if (!part.approved) yield { type: 'tool-denied', name: part.toolCall.toolName };
|
||||
break;
|
||||
case 'tool-output-denied':
|
||||
yield { type: 'tool-denied', name: part.toolName };
|
||||
break;
|
||||
case 'abort':
|
||||
yield { type: 'done' };
|
||||
return;
|
||||
case 'error':
|
||||
sawError = true;
|
||||
yield { type: 'error', error: part.error };
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
yield { type: 'done' };
|
||||
return;
|
||||
}
|
||||
yield { type: 'error', error };
|
||||
return;
|
||||
}
|
||||
|
||||
// A stream that ended in an error has no response messages or usage to
|
||||
// await; touching them would throw NoOutputGeneratedError.
|
||||
if (sawError) return;
|
||||
|
||||
while (compactions.length > 0) yield compactions.shift()!;
|
||||
while (outputs.length > 0) yield outputs.shift()!;
|
||||
while (guardNotices.length > 0) yield { type: 'notice', text: guardNotices.shift()! };
|
||||
|
||||
this.messages.push(...(await result.responseMessages));
|
||||
this.opts.onChange?.(this.messages);
|
||||
|
||||
if (pending.length === 0) {
|
||||
const usage = await result.usage;
|
||||
this.inputTokens += usage.inputTokens ?? 0;
|
||||
this.outputTokens += usage.outputTokens ?? 0;
|
||||
yield { type: 'done', inputTokens: usage.inputTokens, outputTokens: usage.outputTokens };
|
||||
return;
|
||||
}
|
||||
|
||||
const responses: ToolApprovalResponse[] = [];
|
||||
for (const req of pending) {
|
||||
const decision = this.alwaysAllow.has(req.toolName) ? 'always' : await this.opts.askApproval(req);
|
||||
if (decision === 'always') this.alwaysAllow.add(req.toolName);
|
||||
responses.push({
|
||||
type: 'tool-approval-response',
|
||||
approvalId: req.approvalId,
|
||||
approved: decision !== 'deny',
|
||||
...(decision === 'deny' ? { reason: 'User denied this tool call.' } : {}),
|
||||
});
|
||||
}
|
||||
this.messages.push({ role: 'tool', content: responses });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Skills bundled with the binary.
|
||||
*
|
||||
* These are string constants rather than files on disk because `bun build --compile`
|
||||
* only embeds modules reachable through imports; a directory of .md files would be
|
||||
* missing from the shipped binary.
|
||||
*/
|
||||
export const BUILTIN_SKILLS: { name: string; source: string }[] = [
|
||||
{
|
||||
name: 'debug',
|
||||
source: `---
|
||||
name: debug
|
||||
description: Track down a bug whose cause is not obvious. Use when a test fails for unclear reasons, behaviour differs between environments, or an earlier fix did not hold.
|
||||
---
|
||||
|
||||
# Debugging
|
||||
|
||||
Do not guess. A guess that happens to work leaves the real cause in place.
|
||||
|
||||
## Reproduce first
|
||||
|
||||
Find the smallest command that shows the failure and record it with \`remember\`. If you
|
||||
cannot reproduce it, say so and ask what the user did differently — do not proceed on a
|
||||
hypothesis you cannot test.
|
||||
|
||||
## Three hypotheses, then evidence
|
||||
|
||||
Write down at least three causes that would produce this exact symptom. Rank them by how
|
||||
cheap they are to disprove, then disprove them in that order. State which one you are
|
||||
testing before you test it.
|
||||
|
||||
Evidence means observed output: a log line, a failing assertion, a value printed at the
|
||||
point of failure. "It should be X" is not evidence.
|
||||
|
||||
## Bisect when the space is large
|
||||
|
||||
- Recent regression: check what changed last.
|
||||
- Unclear layer: assert the value at each boundary until one is wrong.
|
||||
- Intermittent: run it in a loop and capture the failing case, do not reason about it abstractly.
|
||||
|
||||
## Fix the cause
|
||||
|
||||
Once you know the cause, fix that and nothing else. Do not tidy surrounding code in the
|
||||
same change — a bugfix diff should contain only the bug.
|
||||
|
||||
Write a test that fails before the fix and passes after. If you cannot express the bug as
|
||||
a test, say why.
|
||||
|
||||
## After two failed attempts
|
||||
|
||||
Stop. Re-read the error text literally, character by character. Check your assumption
|
||||
about which code is actually running: the wrong file, a stale build, a shadowed import,
|
||||
or a cached dependency accounts for most "impossible" bugs.
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'review',
|
||||
source: `---
|
||||
name: review
|
||||
description: Review a diff or a file for defects. Use when asked to review, critique, or check code before it ships.
|
||||
---
|
||||
|
||||
# Code review
|
||||
|
||||
Severity order. Do not lead with style.
|
||||
|
||||
1. **Incorrect behaviour** — wrong result, wrong edge case, wrong state after failure.
|
||||
2. **Missing validation at trust boundaries** — user input, network responses, file contents,
|
||||
anything crossing a process line. Internal calls need no defensive checks.
|
||||
3. **Security** — injection, path traversal, secrets in logs or errors, missing authz.
|
||||
4. **Resource handling** — unclosed handles, unbounded growth, unawaited promises.
|
||||
5. **Clarity** — only when it will cause a future defect.
|
||||
|
||||
## For each finding
|
||||
|
||||
State file and line, what breaks, and the change. Show the fix as code when it is short.
|
||||
|
||||
Skip anything a formatter would fix. Skip preference. If a choice is defensible, leave it.
|
||||
|
||||
## Say when it is fine
|
||||
|
||||
A review that invents problems to look thorough is worse than a short one. If the change
|
||||
is correct, say so and stop.
|
||||
|
||||
## Verify, do not assume
|
||||
|
||||
Read the surrounding code before calling something a bug. A "missing" null check often
|
||||
exists one level up. Run the tests if that is what settles it.
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'refactor',
|
||||
source: `---
|
||||
name: refactor
|
||||
description: Restructure code without changing behaviour. Use when asked to refactor, clean up, extract, or reorganise.
|
||||
---
|
||||
|
||||
# Refactoring
|
||||
|
||||
Behaviour must not change. That is the whole constraint.
|
||||
|
||||
## Establish the safety net first
|
||||
|
||||
Run the existing tests and record that they pass. If the code has no tests, write one that
|
||||
pins current behaviour — including the ugly parts — before touching anything. Refactoring
|
||||
untested code is rewriting it.
|
||||
|
||||
## Then move in small steps
|
||||
|
||||
One transformation at a time, tests green between each. Rename, then extract, then move —
|
||||
not all three in one edit. A large refactor that fails leaves you unable to tell which step
|
||||
broke it.
|
||||
|
||||
## What not to do
|
||||
|
||||
- Do not fix bugs while refactoring. Note them, finish, fix separately.
|
||||
- Do not add abstraction for a single caller. Duplication beats a premature interface.
|
||||
- Do not widen the scope. The request was this code, not its neighbours.
|
||||
- Do not change public API unless asked; if it must change, say so first.
|
||||
|
||||
## Done means
|
||||
|
||||
Tests pass, behaviour is identical, and the diff is smaller than the reader feared.
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'test',
|
||||
source: `---
|
||||
name: test
|
||||
description: Write or repair tests. Use when adding coverage, fixing a flaky test, or asked how something should be tested.
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
A test earns its place by failing when the code is wrong.
|
||||
|
||||
## Match the project
|
||||
|
||||
Read two existing test files first. Use their runner, their assertion style, their file
|
||||
layout, their naming. A test that looks foreign is a test nobody maintains.
|
||||
|
||||
## Test behaviour, not implementation
|
||||
|
||||
Assert on what a caller observes. A test that reaches into private state breaks on every
|
||||
refactor and catches nothing.
|
||||
|
||||
Cover: the normal case, the boundaries, and the failure. Failure cases catch more real
|
||||
defects than happy paths.
|
||||
|
||||
## Never do this
|
||||
|
||||
- Do not assert what the code currently returns without knowing it is correct — that pins
|
||||
the bug.
|
||||
- Do not weaken an assertion to make a test pass. If it fails, either the code or the
|
||||
expectation is wrong; find out which.
|
||||
- Do not delete a failing test. It is telling you something.
|
||||
|
||||
## Flaky tests
|
||||
|
||||
A test that passes alone and fails in a suite is a shared-state problem: a global, a
|
||||
temp directory, a port, an unawaited promise, or ordering. Find which, do not add a retry.
|
||||
|
||||
## Verify
|
||||
|
||||
Run the test and watch it fail before the fix, pass after. A test you never saw fail is
|
||||
not known to work.
|
||||
`,
|
||||
},
|
||||
];
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { tool } from 'ai';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { BUILTIN_SKILLS } from './skills-builtin';
|
||||
|
||||
export type SkillOrigin = 'builtin' | 'user' | 'project';
|
||||
|
||||
export type Skill = {
|
||||
name: string;
|
||||
description: string;
|
||||
origin: SkillOrigin;
|
||||
path?: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
const MAX_BODY = 20_000;
|
||||
|
||||
/**
|
||||
* Minimal YAML frontmatter reader: `name` and `description` only.
|
||||
* A real YAML parser would be a dependency for two string fields.
|
||||
*/
|
||||
export function parseSkill(source: string, origin: SkillOrigin, path?: string): Skill | undefined {
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(source.trimStart());
|
||||
if (!match) return undefined;
|
||||
|
||||
const meta: Record<string, string> = {};
|
||||
for (const line of match[1]!.split(/\r?\n/)) {
|
||||
const kv = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(line.trim());
|
||||
if (kv) meta[kv[1]!.toLowerCase()] = kv[2]!.replace(/^["']|["']$/g, '').trim();
|
||||
}
|
||||
|
||||
const name = meta['name'];
|
||||
const description = meta['description'];
|
||||
if (!name || !description) return undefined;
|
||||
|
||||
return { name, description, origin, ...(path ? { path } : {}), body: match[2]!.trim().slice(0, MAX_BODY) };
|
||||
}
|
||||
|
||||
const skillDirs = (cwd: string) => [
|
||||
{ dir: join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'skills'), origin: 'user' as const },
|
||||
{ dir: join(cwd, '.shiro', 'skills'), origin: 'project' as const },
|
||||
];
|
||||
|
||||
/**
|
||||
* Builtin, then user, then project. Later wins, so a project can override a
|
||||
* bundled skill by using the same name.
|
||||
*/
|
||||
export async function loadSkills(cwd = process.cwd()): Promise<Skill[]> {
|
||||
const byName = new Map<string, Skill>();
|
||||
|
||||
for (const { name, source } of BUILTIN_SKILLS) {
|
||||
const skill = parseSkill(source, 'builtin');
|
||||
if (skill) byName.set(skill.name, skill);
|
||||
else byName.delete(name);
|
||||
}
|
||||
|
||||
for (const { dir, origin } of skillDirs(cwd)) {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
for await (const f of new Bun.Glob('*.md').scan({ cwd: dir, onlyFiles: true })) files.push(f);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of files.sort()) {
|
||||
const path = join(dir, file);
|
||||
try {
|
||||
const skill = parseSkill(await Bun.file(path).text(), origin, path);
|
||||
if (skill) byName.set(skill.name, skill);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalogue for the system prompt: names and one-line descriptions only.
|
||||
* Bodies stay out of context until the model asks, which is the point.
|
||||
*/
|
||||
export function renderSkills(skills: Skill[]): string {
|
||||
if (skills.length === 0) return '';
|
||||
const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
|
||||
return [
|
||||
'',
|
||||
'Skills available through the skill tool. Load one when its description matches the task,',
|
||||
'before you start working, and follow it as if the user had written it:',
|
||||
...lines,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function createSkillTool(skills: Skill[]) {
|
||||
const names = skills.map((s) => s.name);
|
||||
return tool({
|
||||
description:
|
||||
'Load a skill: detailed instructions for one kind of task. Call it as soon as a skill description matches ' +
|
||||
`what you are about to do, then follow what it says. Available: ${names.join(', ') || 'none'}.`,
|
||||
inputSchema: z.object({
|
||||
name: z.string().describe('Skill name from the list in your instructions'),
|
||||
}),
|
||||
execute: async ({ name }) => {
|
||||
const skill = skills.find((s) => s.name === name.trim().toLowerCase());
|
||||
if (!skill) throw new Error(`No skill named "${name}". Available: ${names.join(', ') || 'none'}`);
|
||||
return `Skill "${skill.name}" (${skill.origin}). Follow these instructions for this task.\n\n${skill.body}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { skillDirs };
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { NotebookState } from './notebook';
|
||||
|
||||
export type SessionRecord = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
cwd: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
title: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
/** Estimated USD, absent when the model has no known rate. */
|
||||
costUsd?: number;
|
||||
/** Task list and notes, so a resumed session keeps its plan. */
|
||||
notebook?: NotebookState;
|
||||
messages: ModelMessage[];
|
||||
};
|
||||
|
||||
/** Resolved per call so tests can point SHIRO_HOME at a temp directory. */
|
||||
const root = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko');
|
||||
const dir = () => join(root(), 'sessions');
|
||||
|
||||
const file = (id: string) => join(dir(), `${id}.json`);
|
||||
|
||||
export function newId(): string {
|
||||
return Bun.randomUUIDv7();
|
||||
}
|
||||
|
||||
export async function save(rec: SessionRecord): Promise<void> {
|
||||
await Bun.write(file(rec.id), JSON.stringify({ ...rec, updatedAt: new Date().toISOString() }, null, 2));
|
||||
}
|
||||
|
||||
export async function load(id: string): Promise<SessionRecord | undefined> {
|
||||
const f = Bun.file(file(id));
|
||||
if (!(await f.exists())) return undefined;
|
||||
try {
|
||||
return (await f.json()) as SessionRecord;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function list(limit = 20): Promise<SessionRecord[]> {
|
||||
const found: SessionRecord[] = [];
|
||||
// Bun.Glob throws ENOENT on a directory that does not exist yet, which is the
|
||||
// normal state on a fresh install.
|
||||
try {
|
||||
for await (const name of new Bun.Glob('*.json').scan({ cwd: dir(), onlyFiles: true })) {
|
||||
const rec = await load(name.replace(/\.json$/, ''));
|
||||
if (rec) found.push(rec);
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return found.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
|
||||
}
|
||||
|
||||
export async function latest(cwd?: string): Promise<SessionRecord | undefined> {
|
||||
const all = await list(100);
|
||||
return cwd ? all.find((r) => r.cwd === cwd) : all[0];
|
||||
}
|
||||
|
||||
/** Resolves a full id or a unique prefix, so users can type the first few chars. */
|
||||
export async function resolveId(prefix: string): Promise<string | undefined> {
|
||||
if (await Bun.file(file(prefix)).exists()) return prefix;
|
||||
const matches = (await list(100)).filter((r) => r.id.startsWith(prefix));
|
||||
return matches.length === 1 ? matches[0]!.id : undefined;
|
||||
}
|
||||
|
||||
export function titleOf(messages: ModelMessage[]): string {
|
||||
const first = messages.find((m) => m.role === 'user');
|
||||
const text = typeof first?.content === 'string' ? first.content : '';
|
||||
return text.length > 60 ? `${text.slice(0, 60)}...` : text || 'untitled';
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 200;
|
||||
|
||||
/** Per-directory file, hashed because a path is not a safe filename. */
|
||||
const historyFile = (cwd: string) =>
|
||||
join(root(), 'history', `${createHash('sha256').update(cwd).digest('hex').slice(0, 16)}.json`);
|
||||
|
||||
export async function loadHistory(cwd = process.cwd()): Promise<string[]> {
|
||||
const f = Bun.file(historyFile(cwd));
|
||||
if (!(await f.exists())) return [];
|
||||
try {
|
||||
const parsed: unknown = await f.json();
|
||||
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends unless it repeats the previous entry, keeping the newest MAX_HISTORY. */
|
||||
export async function appendHistory(prompt: string, cwd = process.cwd()): Promise<string[]> {
|
||||
const text = prompt.trim();
|
||||
if (!text) return loadHistory(cwd);
|
||||
const existing = await loadHistory(cwd);
|
||||
if (existing.at(-1) === text) return existing;
|
||||
const next = [...existing, text].slice(-MAX_HISTORY);
|
||||
await Bun.write(historyFile(cwd), JSON.stringify(next, null, 2));
|
||||
return next;
|
||||
}
|
||||
|
||||
export { dir as sessionsDir, root as shiroHome };
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { isStepCount, streamText, tool, type LanguageModel, type ToolSet } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { globTool, grepTool, readFileTool } from './tools';
|
||||
|
||||
export type SubagentKind = 'explore' | 'review';
|
||||
|
||||
export type SubagentEvent =
|
||||
| { type: 'start'; id: string; kind: SubagentKind; description: string }
|
||||
| { type: 'step'; id: string; tool: string; summary: string }
|
||||
| { type: 'end'; id: string; ok: boolean; steps: number }
|
||||
| { type: 'error'; id: string; message: string };
|
||||
|
||||
export type SubagentReporter = (event: SubagentEvent) => void;
|
||||
|
||||
const READ_ONLY: ToolSet = { read_file: readFileTool, glob: globTool, grep: grepTool };
|
||||
|
||||
const PROMPTS: Record<SubagentKind, (cwd: string) => string> = {
|
||||
explore: (cwd) => `You are a research subagent inside a coding agent.
|
||||
|
||||
Workspace root: ${cwd}
|
||||
Tools: read_file, glob, grep. You cannot write files, run commands, or ask questions.
|
||||
|
||||
Find what was asked and report once. Rules:
|
||||
- Give file paths with line numbers, plus a short quote where the quote is the answer.
|
||||
- Report what you actually read. If you could not determine something, say so; do not fill the gap.
|
||||
- No preamble, no restating the task, no offers of further help.
|
||||
- Aim for under 30 lines. The parent agent pays for every line you write.`,
|
||||
|
||||
review: (cwd) => `You are a review subagent inside a coding agent.
|
||||
|
||||
Workspace root: ${cwd}
|
||||
Tools: read_file, glob, grep. You cannot write files, run commands, or ask questions.
|
||||
|
||||
Review what was asked and report once. Severity order: incorrect behaviour, missing validation at
|
||||
trust boundaries, security, resource handling, then clarity. For each finding give file, line, what
|
||||
breaks, and the fix. Say plainly when something is correct. Do not invent findings to look thorough.`,
|
||||
};
|
||||
|
||||
const summarize = (input: unknown): string => {
|
||||
if (input === null || typeof input !== 'object') return String(input);
|
||||
const o = input as Record<string, unknown>;
|
||||
const first = o['pattern'] ?? o['path'] ?? o['include'];
|
||||
return typeof first === 'string' ? first : JSON.stringify(o).slice(0, 80);
|
||||
};
|
||||
|
||||
let counter = 0;
|
||||
|
||||
/**
|
||||
* Read-only child agent.
|
||||
*
|
||||
* It runs its own tool loop and returns one message, so the parent pays for the
|
||||
* findings rather than the whole search transcript. No write, bash, or ask tool is
|
||||
* passed in, which is also why a subagent can never trigger an approval prompt.
|
||||
*/
|
||||
export function createTaskTool(opts: {
|
||||
model: LanguageModel;
|
||||
cwd?: string;
|
||||
maxSteps?: number;
|
||||
report?: SubagentReporter;
|
||||
}) {
|
||||
return tool({
|
||||
description:
|
||||
'Delegate a read-only investigation to a subagent that can read, glob, and grep. Use it for questions ' +
|
||||
'spanning many files ("where is auth handled", "every caller of X") and to keep a long search out of your ' +
|
||||
'own context. The subagent sees none of this conversation, so its prompt must be self-contained. ' +
|
||||
'It returns one text report. Do not delegate something you can answer with a single grep.',
|
||||
inputSchema: z.object({
|
||||
description: z.string().describe('Short label shown to the user, 3-6 words'),
|
||||
prompt: z.string().describe('Self-contained instructions: what to find, where to look, what to return'),
|
||||
kind: z
|
||||
.enum(['explore', 'review'])
|
||||
.optional()
|
||||
.describe('explore: find and report. review: critique code for defects. Default explore.'),
|
||||
}),
|
||||
execute: async ({ description, prompt, kind }, { abortSignal }) => {
|
||||
const id = `sub${++counter}`;
|
||||
const flavour: SubagentKind = kind ?? 'explore';
|
||||
const report = opts.report;
|
||||
report?.({ type: 'start', id, kind: flavour, description });
|
||||
|
||||
let steps = 0;
|
||||
let text = '';
|
||||
|
||||
try {
|
||||
const result = streamText({
|
||||
model: opts.model,
|
||||
system: PROMPTS[flavour](opts.cwd ?? process.cwd()),
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
tools: READ_ONLY,
|
||||
stopWhen: isStepCount(opts.maxSteps ?? 20),
|
||||
...(abortSignal ? { abortSignal } : {}),
|
||||
});
|
||||
|
||||
const sink = () => {};
|
||||
void result.responseMessages.then(undefined, sink);
|
||||
void result.usage.then(undefined, sink);
|
||||
void result.steps.then(undefined, sink);
|
||||
void result.finalStep.then(undefined, sink);
|
||||
void result.finishReason.then(undefined, sink);
|
||||
|
||||
for await (const part of result.stream) {
|
||||
if (part.type === 'tool-call') {
|
||||
steps++;
|
||||
report?.({ type: 'step', id, tool: part.toolName, summary: summarize(part.input) });
|
||||
} else if (part.type === 'text-delta') {
|
||||
text += part.text;
|
||||
} else if (part.type === 'error') {
|
||||
// A provider failure arrives as a stream part, not a throw, so it has to
|
||||
// be rethrown here or the subagent silently returns nothing.
|
||||
const message = part.error instanceof Error ? part.error.message : String(part.error);
|
||||
throw part.error instanceof Error ? part.error : new Error(message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
report?.({ type: 'error', id, message });
|
||||
throw e;
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
report?.({ type: 'end', id, ok: trimmed.length > 0, steps });
|
||||
return trimmed || 'Subagent returned no findings.';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const TASK_TOOL_NAME = 'task';
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
import { tool } from 'ai';
|
||||
import { resolve } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { jail, posix, walk } from './ignore';
|
||||
|
||||
/** Max chars returned by any single tool. Beyond this the output is truncated. */
|
||||
const MAX_OUTPUT = 30_000;
|
||||
const MAX_GREP_HITS = 200;
|
||||
/** Bytes sniffed for a NUL to decide a file is not text. */
|
||||
const SNIFF_BYTES = 8192;
|
||||
|
||||
function cap(s: string): string {
|
||||
return s.length <= MAX_OUTPUT ? s : `${s.slice(0, MAX_OUTPUT)}\n... [truncated ${s.length - MAX_OUTPUT} chars]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A NUL byte in the first few KB means this is not text. Cheap, and the same
|
||||
* heuristic git and ripgrep use; without it a model can burn its whole context
|
||||
* on one accidental `read_file dist/binary`.
|
||||
*/
|
||||
async function isBinary(abs: string): Promise<boolean> {
|
||||
const bytes = new Uint8Array(await Bun.file(abs).slice(0, SNIFF_BYTES).arrayBuffer());
|
||||
return bytes.includes(0);
|
||||
}
|
||||
|
||||
export const readFileTool = tool({
|
||||
description: 'Read a UTF-8 text file. Returns contents with 1-based line numbers.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe('File path relative to the workspace root'),
|
||||
offset: z.number().int().min(1).optional().describe('First line to return (1-based)'),
|
||||
limit: z.number().int().min(1).optional().describe('Max lines to return, default 2000'),
|
||||
}),
|
||||
execute: async ({ path, offset = 1, limit = 2000 }) => {
|
||||
const abs = jail(path);
|
||||
const file = Bun.file(abs);
|
||||
if (!(await file.exists())) throw new Error(`No such file: ${path}`);
|
||||
if (await isBinary(abs)) throw new Error(`${path} is a binary file, not text. Use bash if you need to inspect it.`);
|
||||
const lines = (await file.text()).split('\n');
|
||||
const slice = lines.slice(offset - 1, offset - 1 + limit);
|
||||
return cap(slice.map((l, i) => `${offset + i}: ${l}`).join('\n'));
|
||||
},
|
||||
});
|
||||
|
||||
export const writeFileTool = tool({
|
||||
description: 'Create a file or overwrite it completely. Prefer edit_file for existing files.',
|
||||
inputSchema: z.object({
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
}),
|
||||
execute: async ({ path, content }) => {
|
||||
const abs = jail(path);
|
||||
await Bun.write(abs, content);
|
||||
return `Wrote ${content.length} chars to ${path}`;
|
||||
},
|
||||
});
|
||||
|
||||
export const editFileTool = tool({
|
||||
description:
|
||||
'Replace an exact string in a file. oldString must appear exactly once unless replaceAll is true. Include surrounding context to make oldString unique.',
|
||||
inputSchema: z.object({
|
||||
path: z.string(),
|
||||
oldString: z.string().describe('Exact text to find, including whitespace and indentation'),
|
||||
newString: z.string().describe('Replacement text'),
|
||||
replaceAll: z.boolean().optional().describe('Replace every occurrence instead of requiring exactly one'),
|
||||
}),
|
||||
execute: async ({ path, oldString, newString, replaceAll = false }) => {
|
||||
if (oldString === newString) throw new Error('oldString and newString are identical');
|
||||
const abs = jail(path);
|
||||
const file = Bun.file(abs);
|
||||
if (!(await file.exists())) throw new Error(`No such file: ${path}`);
|
||||
const before = await file.text();
|
||||
|
||||
const count = before.split(oldString).length - 1;
|
||||
if (count === 0) throw new Error(`oldString not found in ${path}`);
|
||||
if (count > 1 && !replaceAll) {
|
||||
throw new Error(`oldString appears ${count} times in ${path}. Add surrounding context or set replaceAll.`);
|
||||
}
|
||||
|
||||
const after = replaceAll ? before.split(oldString).join(newString) : before.replace(oldString, newString);
|
||||
await Bun.write(abs, after);
|
||||
return `Replaced ${replaceAll ? count : 1} occurrence(s) in ${path}`;
|
||||
},
|
||||
});
|
||||
|
||||
export const globTool = tool({
|
||||
description:
|
||||
'Find files by glob pattern, e.g. "src/**/*.ts". Skips anything .gitignore excludes. Returns paths relative to the workspace root.',
|
||||
inputSchema: z.object({
|
||||
pattern: z.string(),
|
||||
limit: z.number().int().min(1).optional().describe('Max paths to return, default 200'),
|
||||
includeIgnored: z.boolean().optional().describe('Also search files git ignores'),
|
||||
}),
|
||||
execute: async ({ pattern, limit = 200, includeIgnored = false }) => {
|
||||
const glob = new Bun.Glob(pattern);
|
||||
const hits: string[] = [];
|
||||
for await (const rel of walk({ noIgnore: includeIgnored })) {
|
||||
if (!glob.match(rel)) continue;
|
||||
hits.push(rel);
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits.length ? hits.join('\n') : 'No files matched.';
|
||||
},
|
||||
});
|
||||
|
||||
type GrepArgs = { pattern: string; include?: string; ignoreCase?: boolean; includeIgnored?: boolean };
|
||||
|
||||
/**
|
||||
* ripgrep is 10-100x faster than walking in JS and already understands
|
||||
* .gitignore and binary detection, so use it whenever it is installed.
|
||||
* Output shape stays identical to the fallback so the model sees one format.
|
||||
*/
|
||||
async function grepWithRipgrep({ pattern, include, ignoreCase, includeIgnored }: GrepArgs): Promise<string | undefined> {
|
||||
// --no-require-git: rg skips .gitignore outside a repo by default, but the JS
|
||||
// fallback always honours it, and the two paths must agree.
|
||||
const args = [
|
||||
'--line-number',
|
||||
'--no-heading',
|
||||
'--color',
|
||||
'never',
|
||||
'--no-require-git',
|
||||
'--max-count',
|
||||
String(MAX_GREP_HITS),
|
||||
];
|
||||
if (ignoreCase) args.push('--ignore-case');
|
||||
if (includeIgnored) args.push('--no-ignore');
|
||||
if (include) args.push('--glob', include);
|
||||
args.push('--regexp', pattern, '.');
|
||||
|
||||
let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
|
||||
try {
|
||||
proc = Bun.spawn(['rg', ...args], { cwd: process.cwd(), stdout: 'pipe', stderr: 'pipe', timeout: 60_000 });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
// 0 = matches, 1 = no matches. Anything else means rg could not run the search.
|
||||
if (code > 1) {
|
||||
if (/regex parse error|error parsing/i.test(stderr)) throw new Error(`Invalid regex: ${stderr.trim()}`);
|
||||
return undefined;
|
||||
}
|
||||
if (code === 1) return 'No matches.';
|
||||
|
||||
const hits = stdout
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\r$/, ''))
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const m = /^(.*?):(\d+):(.*)$/.exec(line);
|
||||
if (!m) return line;
|
||||
// rg prefixes every path with the search root and uses native separators.
|
||||
const rel = posix(m[1]!).replace(/^\.\//, '');
|
||||
return `${rel}:${m[2]}: ${m[3]!.slice(0, 300)}`;
|
||||
})
|
||||
.slice(0, MAX_GREP_HITS);
|
||||
|
||||
return cap(hits.join('\n'));
|
||||
}
|
||||
|
||||
async function grepInJs({ pattern, include = '**/*', ignoreCase, includeIgnored }: GrepArgs): Promise<string> {
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = new RegExp(pattern, ignoreCase ? 'i' : '');
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid regex: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
const glob = new Bun.Glob(include);
|
||||
const hits: string[] = [];
|
||||
for await (const rel of walk({ noIgnore: includeIgnored })) {
|
||||
if (!glob.match(rel)) continue;
|
||||
const abs = resolve(process.cwd(), rel);
|
||||
let text: string;
|
||||
try {
|
||||
if (await isBinary(abs)) continue;
|
||||
text = await Bun.file(abs).text();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? '';
|
||||
if (re.test(line)) hits.push(`${rel}:${i + 1}: ${line.slice(0, 300)}`);
|
||||
if (hits.length >= MAX_GREP_HITS) return cap(`${hits.join('\n')}\n... [hit limit ${MAX_GREP_HITS}]`);
|
||||
}
|
||||
}
|
||||
return hits.length ? cap(hits.join('\n')) : 'No matches.';
|
||||
}
|
||||
|
||||
export const grepTool = tool({
|
||||
description:
|
||||
'Search file contents with a regular expression. Skips binaries and anything .gitignore excludes. Returns path:line:text hits.',
|
||||
inputSchema: z.object({
|
||||
pattern: z.string().describe('Regex source. ripgrep syntax when available, otherwise JavaScript'),
|
||||
include: z.string().optional().describe('Glob limiting which files are searched, default "**/*"'),
|
||||
ignoreCase: z.boolean().optional(),
|
||||
includeIgnored: z.boolean().optional().describe('Also search files git ignores'),
|
||||
}),
|
||||
execute: async (args) => (await grepWithRipgrep(args)) ?? (await grepInJs(args)),
|
||||
});
|
||||
|
||||
export type BashOutput = { toolCallId: string; chunk: string };
|
||||
|
||||
/** Set by Session so long-running commands can report progress before exiting. */
|
||||
let bashListener: ((out: BashOutput) => void) | undefined;
|
||||
|
||||
export function onBashOutput(fn: ((out: BashOutput) => void) | undefined): void {
|
||||
bashListener = fn;
|
||||
}
|
||||
|
||||
async function pump(
|
||||
stream: ReadableStream<Uint8Array> | undefined,
|
||||
toolCallId: string,
|
||||
): Promise<string> {
|
||||
if (!stream) return '';
|
||||
const decoder = new TextDecoder();
|
||||
let all = '';
|
||||
for await (const chunk of stream) {
|
||||
const text = decoder.decode(chunk, { stream: true });
|
||||
if (!text) continue;
|
||||
all += text;
|
||||
bashListener?.({ toolCallId, chunk: text });
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
export const bashTool = tool({
|
||||
description: 'Run a shell command in the workspace root. Use for builds, tests, git, and package managers.',
|
||||
inputSchema: z.object({
|
||||
command: z.string(),
|
||||
timeout: z.number().int().min(1000).max(600_000).optional().describe('Timeout in ms, default 120000'),
|
||||
}),
|
||||
execute: async ({ command, timeout = 120_000 }, { toolCallId, abortSignal }) => {
|
||||
const shell = process.platform === 'win32' ? ['cmd', '/c', command] : ['bash', '-lc', command];
|
||||
const proc = Bun.spawn(shell, {
|
||||
cwd: process.cwd(),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout,
|
||||
...(abortSignal ? { signal: abortSignal } : {}),
|
||||
});
|
||||
|
||||
// Drained concurrently: a command that fills one pipe while we block on the
|
||||
// other would deadlock, and buffering both hides progress for minutes.
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
pump(proc.stdout as ReadableStream<Uint8Array>, toolCallId),
|
||||
pump(proc.stderr as ReadableStream<Uint8Array>, toolCallId),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
return cap(
|
||||
[
|
||||
`exit: ${exitCode}`,
|
||||
proc.signalCode && `(killed by ${proc.signalCode}; timeout is ${timeout}ms)`,
|
||||
stdout.trim() && `stdout:\n${stdout.trim()}`,
|
||||
stderr.trim() && `stderr:\n${stderr.trim()}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const tools = {
|
||||
read_file: readFileTool,
|
||||
write_file: writeFileTool,
|
||||
edit_file: editFileTool,
|
||||
glob: globTool,
|
||||
grep: grepTool,
|
||||
bash: bashTool,
|
||||
};
|
||||
|
||||
/** Tools that mutate the workspace or run arbitrary code always ask the user first. */
|
||||
export const MUTATING_TOOLS = ['write_file', 'edit_file', 'bash'] as const;
|
||||
|
||||
export { jail };
|
||||
+792
@@ -0,0 +1,792 @@
|
||||
import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import Spinner from 'ink-spinner';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { parseCommand, matchCommands, type CommandSpec } from '../commands';
|
||||
import { THINKING_LEVELS, VARIANTS } from '../agents';
|
||||
import type { Config } from '../config';
|
||||
import { TODO_MARK, type NotebookState } from '../notebook';
|
||||
import { costOf, formatUsd, usageLine } from '../pricing';
|
||||
import type { ApprovalDecision, ApprovalRequest, Session } from '../session';
|
||||
import type { SubagentEvent } from '../subagent';
|
||||
import { AskPanel, type AskBridge, type AskPending } from './Ask';
|
||||
import { Diff } from './Diff';
|
||||
import { Markdown } from './Markdown';
|
||||
import { Onboard, type OnboardResult } from './Onboard';
|
||||
import { InfoPanel, OutputPanel, StatusBar, SubagentPanel, TodoPanel, type SubagentView } from './Panels';
|
||||
import { PromptInput } from './PromptInput';
|
||||
|
||||
type Line =
|
||||
| { key: string; kind: 'user'; text: string }
|
||||
| { key: string; kind: 'assistant'; text: string }
|
||||
| { key: string; kind: 'tool'; name: string; summary: string; ok: boolean }
|
||||
| { key: string; kind: 'info'; text: string }
|
||||
| { key: string; kind: 'error'; text: string };
|
||||
|
||||
type NewLine = Line extends infer T ? (T extends Line ? Omit<T, 'key'> : never) : never;
|
||||
|
||||
type Pending = { req: ApprovalRequest; resolve: (d: ApprovalDecision) => void };
|
||||
|
||||
/** Bridges Session's promise-based approval callback into React state. */
|
||||
export type ApprovalBridge = {
|
||||
bind: (fn: (p: Pending | undefined) => void) => void;
|
||||
ask: (req: ApprovalRequest) => Promise<ApprovalDecision>;
|
||||
};
|
||||
|
||||
export function createApprovalBridge(): ApprovalBridge {
|
||||
let setter: ((p: Pending | undefined) => void) | undefined;
|
||||
return {
|
||||
bind(fn) {
|
||||
setter = fn;
|
||||
},
|
||||
ask(req) {
|
||||
return new Promise((resolve) => {
|
||||
if (!setter) return resolve('deny'); // UI not mounted: fail closed
|
||||
setter({
|
||||
req,
|
||||
resolve: (d) => {
|
||||
setter?.(undefined);
|
||||
resolve(d);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One-way channel for out-of-band notices, e.g. an endpoint fallback. */
|
||||
export type NoticeBus = {
|
||||
bind: (fn: (text: string) => void) => void;
|
||||
emit: (text: string) => void;
|
||||
};
|
||||
|
||||
export function createNoticeBus(): NoticeBus {
|
||||
const queued: string[] = [];
|
||||
let sink: ((text: string) => void) | undefined;
|
||||
return {
|
||||
bind(fn) {
|
||||
sink = fn;
|
||||
for (const text of queued.splice(0)) fn(text);
|
||||
},
|
||||
emit(text) {
|
||||
if (sink) sink(text);
|
||||
else queued.push(text);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Subagent progress, from the task tool to the panel. */
|
||||
export type SubagentBus = {
|
||||
bind: (fn: (event: SubagentEvent) => void) => void;
|
||||
emit: (event: SubagentEvent) => void;
|
||||
};
|
||||
|
||||
export function createSubagentBus(): SubagentBus {
|
||||
const queued: SubagentEvent[] = [];
|
||||
let sink: ((event: SubagentEvent) => void) | undefined;
|
||||
return {
|
||||
bind(fn) {
|
||||
sink = fn;
|
||||
for (const event of queued.splice(0)) fn(event);
|
||||
},
|
||||
emit(event) {
|
||||
if (sink) sink(event);
|
||||
else queued.push(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Folds a subagent event into the panel's view, keeping finished agents visible. */
|
||||
export function applySubagentEvent(current: SubagentView[], event: SubagentEvent): SubagentView[] {
|
||||
switch (event.type) {
|
||||
case 'start':
|
||||
return [
|
||||
...current,
|
||||
{ id: event.id, kind: event.kind, description: event.description, steps: [], status: 'running' },
|
||||
];
|
||||
case 'step':
|
||||
return current.map((a) =>
|
||||
a.id === event.id ? { ...a, steps: [...a.steps, { tool: event.tool, summary: event.summary }] } : a,
|
||||
);
|
||||
case 'end':
|
||||
return current.map((a) => (a.id === event.id ? { ...a, status: event.ok ? 'done' : 'failed' } : a));
|
||||
case 'error':
|
||||
return current.map((a) => (a.id === event.id ? { ...a, status: 'failed', error: event.message } : a));
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the slash commands need from the outside world. */
|
||||
export type AppHooks = {
|
||||
sessionId: string;
|
||||
config: () => Config;
|
||||
switchModel: (id: string) => string;
|
||||
switchAgent: (name: string) => string;
|
||||
switchThinking: (level: string) => string;
|
||||
agentName: () => string;
|
||||
thinkingLevel: () => string;
|
||||
applyProvider: (result: OnboardResult) => Promise<string>;
|
||||
listModels: () => Promise<{ models: string[]; warning?: string }>;
|
||||
listSessions: () => Promise<string>;
|
||||
listSkills: () => string;
|
||||
listPlugins: () => string;
|
||||
listMemory: () => Promise<string>;
|
||||
summarizeMemory: () => Promise<string>;
|
||||
resumeSession: (idOrPrefix: string) => Promise<string>;
|
||||
saveSession: () => Promise<string>;
|
||||
/** Loaded AGENTS.md-style files, for /context. */
|
||||
instructionFiles: () => string[];
|
||||
/** Prompt to hand the model for /init. */
|
||||
initPrompt: string;
|
||||
history: string[];
|
||||
recordPrompt: (text: string) => void;
|
||||
};
|
||||
|
||||
let seq = 0;
|
||||
const nextKey = () => `l${seq++}`;
|
||||
|
||||
function preview(input: unknown): string {
|
||||
if (input === null || typeof input !== 'object') return String(input);
|
||||
const o = input as Record<string, unknown>;
|
||||
const first = o['command'] ?? o['path'] ?? o['pattern'] ?? o['description'] ?? o['question'] ?? o['name'];
|
||||
if (typeof first === 'string') return first.length > 90 ? `${first.slice(0, 90)}...` : first;
|
||||
|
||||
// A tool with no obvious label, e.g. todo_write, gets a shape rather than a
|
||||
// JSON dump; the panels below already show the content.
|
||||
const todos = o['todos'];
|
||||
if (Array.isArray(todos)) return `${todos.length} task${todos.length === 1 ? '' : 's'}`;
|
||||
const keys = Object.keys(o);
|
||||
return keys.length === 0 ? '' : keys.slice(0, 3).join(', ');
|
||||
}
|
||||
|
||||
function ApprovalDetail({ name, input }: { name: string; input: unknown }) {
|
||||
const o = (input ?? {}) as Record<string, unknown>;
|
||||
if (name === 'bash') return <Text dimColor>{String(o['command'] ?? '')}</Text>;
|
||||
if (name === 'write_file') {
|
||||
const content = String(o['content'] ?? '');
|
||||
return <Diff before="" after={content} path={`${String(o['path'])} (new content)`} />;
|
||||
}
|
||||
if (name === 'edit_file') {
|
||||
return <Diff before={String(o['oldString'] ?? '')} after={String(o['newString'] ?? '')} path={String(o['path'])} />;
|
||||
}
|
||||
return <Text dimColor>{JSON.stringify(input, null, 2)}</Text>;
|
||||
}
|
||||
|
||||
function Approval({ pending }: { pending: Pending }) {
|
||||
useInput((input, key) => {
|
||||
const c = input.toLowerCase();
|
||||
if (c === 'y' || key.return) pending.resolve('once');
|
||||
else if (c === 'a') pending.resolve('always');
|
||||
else if (c === 'n' || key.escape) pending.resolve('deny');
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
|
||||
<Text color="yellow" bold>
|
||||
{pending.req.toolName} wants to run
|
||||
</Text>
|
||||
<ApprovalDetail name={pending.req.toolName} input={pending.req.input} />
|
||||
<Text>
|
||||
<Text color="green">y</Text> allow once | <Text color="green">a</Text> always allow {pending.req.toolName} |{' '}
|
||||
<Text color="red">n</Text> deny
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandMenu({ matches, index }: { matches: CommandSpec[]; index: number }) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{matches.map((c, i) => (
|
||||
<Text key={c.name} color={i === index ? 'cyan' : undefined} dimColor={i !== index}>
|
||||
{i === index ? '> ' : ' '}
|
||||
{`/${c.name}${c.arg ? ` ${c.arg}` : ''}`.padEnd(18)} {c.summary}
|
||||
</Text>
|
||||
))}
|
||||
<Text dimColor>up/down move | tab complete | enter run | esc dismiss</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function App({
|
||||
session,
|
||||
bridge,
|
||||
header,
|
||||
hooks,
|
||||
notices,
|
||||
askBridge,
|
||||
subagents,
|
||||
needsProvider = false,
|
||||
}: {
|
||||
session: Session;
|
||||
bridge: ApprovalBridge;
|
||||
header: string;
|
||||
hooks: AppHooks;
|
||||
notices?: NoticeBus;
|
||||
askBridge?: AskBridge;
|
||||
subagents?: SubagentBus;
|
||||
needsProvider?: boolean;
|
||||
}) {
|
||||
const { exit } = useApp();
|
||||
const { write } = useStdout();
|
||||
const [history, setHistory] = useState<Line[]>([]);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [live, setLive] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pending, setPending] = useState<Pending | undefined>();
|
||||
const [asking, setAsking] = useState<AskPending | undefined>();
|
||||
const [onboarding, setOnboarding] = useState(needsProvider);
|
||||
const [unconfigured, setUnconfigured] = useState(needsProvider);
|
||||
const [modelPicker, setModelPicker] = useState<string[] | undefined>();
|
||||
const [agentPicker, setAgentPicker] = useState(false);
|
||||
const [thinkPicker, setThinkPicker] = useState(false);
|
||||
const [menuIndex, setMenuIndex] = useState(0);
|
||||
const [menuDismissed, setMenuDismissed] = useState(false);
|
||||
const [inputGeneration, setInputGeneration] = useState(0);
|
||||
const [toolOutput, setToolOutput] = useState('');
|
||||
const [recall, setRecall] = useState<string[]>(hooks.history);
|
||||
const [notebook, setNotebook] = useState<NotebookState>(session.notebook.state());
|
||||
const [agents, setAgents] = useState<SubagentView[]>([]);
|
||||
const [panel, setPanel] = useState<{ title: string; hint?: string; body: string } | undefined>();
|
||||
|
||||
const modal = pending !== undefined || asking !== undefined || onboarding;
|
||||
const anyPicker = modelPicker !== undefined || agentPicker || thinkPicker;
|
||||
const matches = matchCommands(draft);
|
||||
const menuOpen = matches.length > 0 && !menuDismissed && !busy && !modal && !anyPicker && !panel;
|
||||
const highlighted = matches[Math.min(menuIndex, matches.length - 1)];
|
||||
|
||||
useEffect(() => bridge.bind(setPending), [bridge]);
|
||||
useEffect(() => askBridge?.bind(setAsking), [askBridge]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subagents?.bind((event) => {
|
||||
setAgents((current) => applySubagentEvent(current, event));
|
||||
}),
|
||||
[subagents],
|
||||
);
|
||||
|
||||
// Ink re-renders the whole tree per setState, so deltas accumulate in a ref
|
||||
// and are flushed on a timer instead of once per token.
|
||||
const text = useRef('');
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
setLive((s) => (s === text.current ? s : text.current));
|
||||
}, 60);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const push = useCallback((line: NewLine) => {
|
||||
setHistory((h) => [...h, { ...line, key: nextKey() }]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => notices?.bind((text) => push({ kind: 'info', text })), [notices, push]);
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) session.abort();
|
||||
},
|
||||
{ isActive: busy && !modal },
|
||||
);
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (!key.escape) return;
|
||||
setModelPicker(undefined);
|
||||
setAgentPicker(false);
|
||||
setThinkPicker(false);
|
||||
},
|
||||
{ isActive: anyPicker },
|
||||
);
|
||||
|
||||
// PromptInput hands up/down/tab/esc to us first, so the menu and any open panel
|
||||
// can claim them before the input treats them as editing keys.
|
||||
const handleInputKey = useCallback(
|
||||
(_input: string, key: { upArrow: boolean; downArrow: boolean; tab: boolean; escape: boolean }) => {
|
||||
if (key.escape && panel) {
|
||||
setPanel(undefined);
|
||||
return true;
|
||||
}
|
||||
if (!menuOpen) return false;
|
||||
if (key.escape) {
|
||||
setMenuDismissed(true);
|
||||
return true;
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setMenuIndex((i) => (i - 1 + matches.length) % matches.length);
|
||||
return true;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setMenuIndex((i) => (i + 1) % matches.length);
|
||||
return true;
|
||||
}
|
||||
if (key.tab && highlighted) {
|
||||
setDraft(highlighted.arg ? `/${highlighted.name} ` : `/${highlighted.name}`);
|
||||
setMenuIndex(0);
|
||||
setMenuDismissed(true);
|
||||
setInputGeneration((g) => g + 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[highlighted, matches.length, menuOpen, panel],
|
||||
);
|
||||
|
||||
const onDraftChange = useCallback((value: string) => {
|
||||
setDraft(value);
|
||||
setMenuIndex(0);
|
||||
setMenuDismissed(false);
|
||||
}, []);
|
||||
|
||||
const runTurn = useCallback(
|
||||
async (value: string) => {
|
||||
setBusy(true);
|
||||
text.current = '';
|
||||
|
||||
for await (const ev of session.send(value)) {
|
||||
switch (ev.type) {
|
||||
case 'text':
|
||||
text.current += ev.text;
|
||||
break;
|
||||
case 'tool-call':
|
||||
push({ kind: 'tool', name: ev.name, summary: preview(ev.input), ok: true });
|
||||
break;
|
||||
case 'tool-output':
|
||||
setToolOutput((s) => `${s}${ev.chunk}`.slice(-2000));
|
||||
break;
|
||||
case 'tool-error':
|
||||
push({ kind: 'tool', name: ev.name, summary: String(ev.error), ok: false });
|
||||
break;
|
||||
case 'tool-result':
|
||||
setToolOutput('');
|
||||
setNotebook(session.notebook.state());
|
||||
break;
|
||||
case 'tool-denied':
|
||||
push({ kind: 'info', text: `denied ${ev.name}` });
|
||||
break;
|
||||
case 'notice':
|
||||
push({ kind: 'info', text: ev.text });
|
||||
break;
|
||||
case 'compacted':
|
||||
push({ kind: 'info', text: `context compacted: ${ev.before} messages pruned to ${ev.after} on the wire` });
|
||||
break;
|
||||
case 'error':
|
||||
push({ kind: 'error', text: ev.error instanceof Error ? ev.error.message : String(ev.error) });
|
||||
break;
|
||||
case 'done': {
|
||||
const full = text.current.trim();
|
||||
text.current = '';
|
||||
setLive('');
|
||||
setToolOutput('');
|
||||
setAgents([]);
|
||||
setHistory((h) => {
|
||||
const merged: Line[] = [...h];
|
||||
if (full) merged.push({ kind: 'assistant', text: full, key: nextKey() });
|
||||
if (ev.inputTokens !== undefined) {
|
||||
merged.push({
|
||||
kind: 'info',
|
||||
text: `${usageLine(hooks.config().model, ev.inputTokens, ev.outputTokens ?? 0)} (~${session.estimatedTokens()} in context)`,
|
||||
key: nextKey(),
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
},
|
||||
[hooks, push, session],
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
async (raw: string) => {
|
||||
setDraft('');
|
||||
setMenuIndex(0);
|
||||
setMenuDismissed(false);
|
||||
setPanel(undefined);
|
||||
|
||||
// Enter on an open menu runs the highlighted entry, so `/mo` + enter works.
|
||||
const chosen = menuOpen && highlighted ? `/${highlighted.name}` : raw;
|
||||
const action = parseCommand(chosen);
|
||||
|
||||
switch (action.type) {
|
||||
case 'none':
|
||||
return;
|
||||
case 'exit':
|
||||
return exit();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Nothing can reach the model until a provider is configured.
|
||||
if (unconfigured && action.type !== 'provider' && action.type !== 'info') {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
push({ kind: 'error', text: 'no provider configured yet - run /provider' });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'clear':
|
||||
session.reset();
|
||||
setHistory([]);
|
||||
setNotebook(session.notebook.state());
|
||||
// <Static> lines are already committed to the scrollback, so clearing
|
||||
// React state alone leaves them on screen. Wipe screen + scrollback.
|
||||
write('\u001B[2J\u001B[3J\u001B[H');
|
||||
return;
|
||||
case 'info':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setPanel({ title: 'commands', hint: 'type / for the menu', body: action.text });
|
||||
return;
|
||||
case 'unknown':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
push({ kind: 'error', text: `unknown command /${action.name} - try /help` });
|
||||
return;
|
||||
case 'tools':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setPanel({
|
||||
title: 'tools',
|
||||
hint: `${session.activeTools().length} offered this turn of ${Object.keys(session.tools).length} registered`,
|
||||
body: session
|
||||
.activeTools()
|
||||
.sort()
|
||||
.map((t) => `- \`${t}\``)
|
||||
.join('\n'),
|
||||
});
|
||||
return;
|
||||
case 'cost': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
const model = hooks.config().model;
|
||||
const spend = costOf(model, session.inputTokens, session.outputTokens);
|
||||
setPanel({
|
||||
title: 'cost',
|
||||
hint: `session ${hooks.sessionId}`,
|
||||
body: [
|
||||
`- model: \`${model}\``,
|
||||
`- billed: ${session.inputTokens} in / ${session.outputTokens} out`,
|
||||
`- spend: ${spend === undefined ? 'unpriced model' : formatUsd(spend)}`,
|
||||
`- context: ~${session.estimatedTokens()} tokens`,
|
||||
`- agent: \`${hooks.agentName()}\` thinking \`${hooks.thinkingLevel()}\``,
|
||||
].join('\n'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'context': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
const files = hooks.instructionFiles();
|
||||
setPanel({
|
||||
title: 'project instructions',
|
||||
body: files.length
|
||||
? files.map((f) => `- \`${f}\``).join('\n')
|
||||
: 'No `AGENTS.md`, `CLAUDE.md`, or `.shiro.md` found. Run `/init` to write one.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'todos': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
const { todos } = session.notebook.state();
|
||||
setPanel({
|
||||
title: 'task list',
|
||||
body: todos.length
|
||||
? todos.map((t) => `- ${TODO_MARK[t.status]} ${t.content}${t.note ? ` (${t.note})` : ''}`).join('\n')
|
||||
: 'No task list yet.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'notes': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setPanel({ title: 'project memory', body: await hooks.listMemory() });
|
||||
return;
|
||||
}
|
||||
case 'agent': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
if (action.agent) {
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchAgent(action.agent) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setAgentPicker(true);
|
||||
return;
|
||||
}
|
||||
case 'think': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
if (action.level) {
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchThinking(action.level) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setThinkPicker(true);
|
||||
return;
|
||||
}
|
||||
case 'skills':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setPanel({ title: 'skills', hint: 'the agent loads one with the skill tool', body: hooks.listSkills() });
|
||||
return;
|
||||
case 'plugins':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setPanel({ title: 'plugins', body: hooks.listPlugins() });
|
||||
return;
|
||||
case 'memory': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setBusy(true);
|
||||
try {
|
||||
push({ kind: 'info', text: await hooks.summarizeMemory() });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
case 'init':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
await runTurn(hooks.initPrompt);
|
||||
return;
|
||||
case 'model':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchModel(action.model) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
return;
|
||||
case 'sessions':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
push({ kind: 'info', text: await hooks.listSessions() });
|
||||
return;
|
||||
case 'save':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
push({ kind: 'info', text: await hooks.saveSession() });
|
||||
return;
|
||||
case 'resume':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
try {
|
||||
const msg = await hooks.resumeSession(action.id);
|
||||
setHistory([]);
|
||||
push({ kind: 'info', text: msg });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
return;
|
||||
case 'provider':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setOnboarding(true);
|
||||
return;
|
||||
case 'models': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setBusy(true);
|
||||
const { models, warning } = await hooks.listModels();
|
||||
setBusy(false);
|
||||
if (warning) push({ kind: 'info', text: `could not list models: ${warning}` });
|
||||
if (models.length === 0) {
|
||||
push({ kind: 'error', text: 'no models to choose from - use /model <id> or /provider' });
|
||||
return;
|
||||
}
|
||||
setModelPicker(models);
|
||||
return;
|
||||
}
|
||||
case 'compact': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setBusy(true);
|
||||
try {
|
||||
const { before, after } = await session.summarize();
|
||||
push({ kind: 'info', text: `compacted ${before} messages into ${after}` });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
case 'prompt':
|
||||
push({ kind: 'user', text: action.text });
|
||||
hooks.recordPrompt(action.text);
|
||||
setRecall((h) => (h.at(-1) === action.text ? h : [...h, action.text]));
|
||||
await runTurn(action.text);
|
||||
return;
|
||||
}
|
||||
},
|
||||
[exit, highlighted, hooks, menuOpen, push, runTurn, session, unconfigured, write],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Static items={history}>
|
||||
{(line) => (
|
||||
<Box key={line.key} flexDirection="column" marginBottom={1}>
|
||||
{line.kind === 'user' && <Text color="cyan">{`> ${line.text}`}</Text>}
|
||||
{line.kind === 'assistant' && <Markdown text={line.text} />}
|
||||
{line.kind === 'tool' && (
|
||||
<Text color={line.ok ? 'magenta' : 'red'}>
|
||||
{line.ok ? '*' : 'x'} {line.name}({line.summary})
|
||||
</Text>
|
||||
)}
|
||||
{line.kind === 'info' && <Text dimColor>{line.text}</Text>}
|
||||
{line.kind === 'error' && <Text color="red">error: {line.text}</Text>}
|
||||
</Box>
|
||||
)}
|
||||
</Static>
|
||||
|
||||
{history.length === 0 && (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>{header}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{agents.length > 0 && <SubagentPanel agents={agents} />}
|
||||
|
||||
{notebook.todos.length > 0 && <TodoPanel todos={notebook.todos} />}
|
||||
|
||||
{live.length > 0 && (
|
||||
<Box marginBottom={1}>
|
||||
<Markdown text={live} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{panel && (
|
||||
<InfoPanel title={panel.title} {...(panel.hint ? { hint: panel.hint } : {})} lines={panel.body} />
|
||||
)}
|
||||
|
||||
{asking && <AskPanel pending={asking} />}
|
||||
|
||||
{pending && <Approval pending={pending} />}
|
||||
|
||||
{onboarding && (
|
||||
<Onboard
|
||||
current={hooks.config()}
|
||||
onCancel={() => {
|
||||
setOnboarding(false);
|
||||
push({ kind: 'info', text: 'provider setup cancelled' });
|
||||
}}
|
||||
onDone={async (result) => {
|
||||
setOnboarding(false);
|
||||
try {
|
||||
push({ kind: 'info', text: await hooks.applyProvider(result) });
|
||||
setUnconfigured(false);
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modelPicker && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>
|
||||
Choose a model ({modelPicker.length} available)
|
||||
</Text>
|
||||
<Text dimColor>enter to select, esc to cancel</Text>
|
||||
<SelectInput
|
||||
items={modelPicker.map((m) => ({ key: m, label: m, value: m }))}
|
||||
limit={10}
|
||||
initialIndex={Math.max(0, modelPicker.indexOf(hooks.config().model))}
|
||||
onSelect={(item) => {
|
||||
setModelPicker(undefined);
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchModel(item.value) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{agentPicker && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>
|
||||
Choose an agent
|
||||
</Text>
|
||||
<Text dimColor>enter to select, esc to cancel</Text>
|
||||
<SelectInput
|
||||
items={VARIANTS.map((v) => ({
|
||||
key: v.name,
|
||||
label: `${v.name.padEnd(8)} ${v.summary}`,
|
||||
value: v.name,
|
||||
}))}
|
||||
limit={8}
|
||||
initialIndex={Math.max(
|
||||
0,
|
||||
VARIANTS.findIndex((v) => v.name === hooks.agentName()),
|
||||
)}
|
||||
onSelect={(item) => {
|
||||
setAgentPicker(false);
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchAgent(item.value) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{thinkPicker && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>
|
||||
Thinking level
|
||||
</Text>
|
||||
<Text dimColor>higher costs more and is slower; enter to select, esc to cancel</Text>
|
||||
<SelectInput
|
||||
items={THINKING_LEVELS.map((l) => ({ key: l, label: l, value: l }))}
|
||||
limit={8}
|
||||
initialIndex={Math.max(0, THINKING_LEVELS.indexOf(hooks.thinkingLevel() as (typeof THINKING_LEVELS)[number]))}
|
||||
onSelect={(item) => {
|
||||
setThinkPicker(false);
|
||||
try {
|
||||
push({ kind: 'info', text: hooks.switchThinking(item.value) });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{busy && !modal && (
|
||||
<Box flexDirection="column">
|
||||
<OutputPanel text={toolOutput} />
|
||||
<Text color="yellow">
|
||||
<Spinner type="dots" /> <Text dimColor>working... esc to interrupt</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!busy && !modal && !anyPicker && (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color="cyan">{'> '}</Text>
|
||||
<PromptInput
|
||||
key={inputGeneration}
|
||||
value={draft}
|
||||
onChange={onDraftChange}
|
||||
onSubmit={submit}
|
||||
history={recall}
|
||||
onKey={handleInputKey}
|
||||
placeholder="ask shiro-neko... (/ for commands)"
|
||||
/>
|
||||
</Box>
|
||||
{menuOpen && <CommandMenu matches={matches} index={Math.min(menuIndex, matches.length - 1)} />}
|
||||
<StatusBar
|
||||
model={hooks.config().model}
|
||||
agent={hooks.agentName()}
|
||||
thinking={hooks.thinkingLevel()}
|
||||
contextTokens={session.estimatedTokens()}
|
||||
cost={(() => {
|
||||
const spend = costOf(hooks.config().model, session.inputTokens, session.outputTokens);
|
||||
return spend === undefined ? 'unpriced' : formatUsd(spend);
|
||||
})()}
|
||||
toolCount={session.activeTools().length}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import React, { useState } from 'react';
|
||||
import type { AskRequest } from '../ask';
|
||||
import { InlineMarkdown } from './Markdown';
|
||||
import { PromptInput } from './PromptInput';
|
||||
|
||||
export type AskPending = { req: AskRequest; resolve: (answers: string[] | undefined) => void };
|
||||
|
||||
/** Bridges the ask tool's promise into React state, the same shape as the approval bridge. */
|
||||
export type AskBridge = {
|
||||
bind: (fn: (p: AskPending | undefined) => void) => void;
|
||||
ask: (req: AskRequest) => Promise<string[] | undefined>;
|
||||
};
|
||||
|
||||
export function createAskBridge(): AskBridge {
|
||||
let setter: ((p: AskPending | undefined) => void) | undefined;
|
||||
return {
|
||||
bind(fn) {
|
||||
setter = fn;
|
||||
},
|
||||
ask(req) {
|
||||
return new Promise((resolve) => {
|
||||
// No UI mounted means no one can answer; resolving undefined lets the tool
|
||||
// tell the model to decide for itself rather than hanging forever.
|
||||
if (!setter) return resolve(undefined);
|
||||
setter({
|
||||
req,
|
||||
resolve: (answers) => {
|
||||
setter?.(undefined);
|
||||
resolve(answers);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TYPE_YOUR_OWN = '__own__';
|
||||
|
||||
/**
|
||||
* The question popup.
|
||||
*
|
||||
* Options become a picker; no options, or "type your own", falls back to free text.
|
||||
* Escape resolves undefined rather than leaving the tool waiting.
|
||||
*/
|
||||
export function AskPanel({ pending }: { pending: AskPending }) {
|
||||
const { question, options, multiple } = pending.req;
|
||||
const [chosen, setChosen] = useState<string[]>([]);
|
||||
const [typing, setTyping] = useState(!options || options.length === 0);
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) pending.resolve(undefined);
|
||||
},
|
||||
{ isActive: !typing },
|
||||
);
|
||||
|
||||
const items = [
|
||||
...(options ?? []).map((o) => ({
|
||||
key: o.label,
|
||||
label: chosen.includes(o.label) ? `[x] ${o.label}` : multiple ? `[ ] ${o.label}` : o.label,
|
||||
value: o.label,
|
||||
})),
|
||||
...(multiple && chosen.length > 0 ? [{ key: '__done__', label: `-- submit ${chosen.length} --`, value: '__done__' }] : []),
|
||||
{ key: TYPE_YOUR_OWN, label: 'type your own answer...', value: TYPE_YOUR_OWN },
|
||||
];
|
||||
|
||||
const detailOf = (label: string) => (options ?? []).find((o) => o.label === label)?.detail;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="double" borderColor="yellow" paddingX={1}>
|
||||
<Text color="yellow" bold>
|
||||
shiro is asking
|
||||
</Text>
|
||||
<Box marginBottom={1}>
|
||||
<InlineMarkdown text={question} />
|
||||
</Box>
|
||||
|
||||
{typing ? (
|
||||
<Box>
|
||||
<Text color="yellow">{'> '}</Text>
|
||||
<PromptInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) => pending.resolve(v.trim() ? [v.trim()] : undefined)}
|
||||
placeholder="type your answer, enter to send"
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
<SelectInput
|
||||
items={items}
|
||||
limit={10}
|
||||
onSelect={(item) => {
|
||||
if (item.value === TYPE_YOUR_OWN) return setTyping(true);
|
||||
if (item.value === '__done__') return pending.resolve(chosen);
|
||||
if (!multiple) return pending.resolve([item.value]);
|
||||
setChosen((c) => (c.includes(item.value) ? c.filter((x) => x !== item.value) : [...c, item.value]));
|
||||
}}
|
||||
onHighlight={(item) => {
|
||||
const detail = detailOf(item.value);
|
||||
if (detail) setDraft(detail);
|
||||
else setDraft('');
|
||||
}}
|
||||
/>
|
||||
{draft.length > 0 && <Text dimColor>{draft}</Text>}
|
||||
<Text dimColor>
|
||||
{multiple ? 'space/enter toggles, pick submit when done' : 'enter to choose'} | esc to skip
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import React from 'react';
|
||||
|
||||
export type DiffLine = { kind: 'context' | 'add' | 'remove'; text: string };
|
||||
|
||||
/**
|
||||
* Line-level diff by longest common subsequence. O(n*m) is fine here because an
|
||||
* edit_file payload is a handful of lines, not a whole file.
|
||||
*/
|
||||
export function diffLines(before: string, after: string): DiffLine[] {
|
||||
const a = before.split('\n');
|
||||
const b = after.split('\n');
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
|
||||
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
|
||||
}
|
||||
}
|
||||
|
||||
const out: DiffLine[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
out.push({ kind: 'context', text: a[i]! });
|
||||
i++;
|
||||
j++;
|
||||
} else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
|
||||
out.push({ kind: 'remove', text: a[i]! });
|
||||
i++;
|
||||
} else {
|
||||
out.push({ kind: 'add', text: b[j]! });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) out.push({ kind: 'remove', text: a[i++]! });
|
||||
while (j < m) out.push({ kind: 'add', text: b[j++]! });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drops runs of unchanged lines longer than `context` on both sides of a change. */
|
||||
export function collapseContext(lines: DiffLine[], context = 2): (DiffLine | { kind: 'gap'; count: number })[] {
|
||||
const keep = new Set<number>();
|
||||
lines.forEach((line, i) => {
|
||||
if (line.kind === 'context') return;
|
||||
for (let k = i - context; k <= i + context; k++) if (k >= 0 && k < lines.length) keep.add(k);
|
||||
});
|
||||
|
||||
const out: (DiffLine | { kind: 'gap'; count: number })[] = [];
|
||||
let skipped = 0;
|
||||
lines.forEach((line, i) => {
|
||||
if (keep.has(i)) {
|
||||
if (skipped > 0) {
|
||||
out.push({ kind: 'gap', count: skipped });
|
||||
skipped = 0;
|
||||
}
|
||||
out.push(line);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
});
|
||||
if (skipped > 0) out.push({ kind: 'gap', count: skipped });
|
||||
return out;
|
||||
}
|
||||
|
||||
const MAX_RENDERED = 40;
|
||||
|
||||
export function Diff({ before, after, path }: { before: string; after: string; path?: string }) {
|
||||
const all = collapseContext(diffLines(before, after));
|
||||
const shown = all.slice(0, MAX_RENDERED);
|
||||
const hidden = all.length - shown.length;
|
||||
const added = all.filter((l) => l.kind === 'add').length;
|
||||
const removed = all.filter((l) => l.kind === 'remove').length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{path && (
|
||||
<Text>
|
||||
<Text bold>{path}</Text> <Text color="green">+{added}</Text> <Text color="red">-{removed}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{shown.map((line, i) =>
|
||||
line.kind === 'gap' ? (
|
||||
<Text key={i} dimColor>
|
||||
{` ... ${line.count} unchanged line${line.count === 1 ? '' : 's'}`}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
key={i}
|
||||
color={line.kind === 'add' ? 'green' : line.kind === 'remove' ? 'red' : undefined}
|
||||
dimColor={line.kind === 'context'}
|
||||
>
|
||||
{`${line.kind === 'add' ? ' + ' : line.kind === 'remove' ? ' - ' : ' '}${line.text}`}
|
||||
</Text>
|
||||
),
|
||||
)}
|
||||
{hidden > 0 && <Text dimColor>{` ... ${hidden} more diff lines`}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import React from 'react';
|
||||
import { parseInline, parseMarkdown, type Block, type Span } from '../markdown';
|
||||
|
||||
const HEADING_COLOR = ['cyan', 'cyan', 'blue', 'blue', 'gray', 'gray'] as const;
|
||||
|
||||
function Inline({ spans }: { spans: Span[] }) {
|
||||
return (
|
||||
<Text>
|
||||
{spans.map((s, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
bold={s.bold}
|
||||
italic={s.italic}
|
||||
strikethrough={s.strike}
|
||||
underline={s.link}
|
||||
color={s.code ? 'yellow' : s.link ? 'blue' : undefined}
|
||||
>
|
||||
{s.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ language, lines }: { language: string; lines: string[] }) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||
{language.length > 0 && <Text dimColor>{language}</Text>}
|
||||
{lines.map((l, i) => (
|
||||
<Text key={i} color="green">
|
||||
{l.length > 0 ? l : ' '}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockView({ block, width }: { block: Block; width: number }) {
|
||||
switch (block.kind) {
|
||||
case 'heading':
|
||||
return (
|
||||
<Box marginTop={block.level === 1 ? 1 : 0}>
|
||||
<Text bold color={HEADING_COLOR[block.level - 1] ?? 'gray'}>
|
||||
<Inline spans={block.spans} />
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
case 'paragraph':
|
||||
return <Inline spans={block.spans} />;
|
||||
case 'bullet':
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{`${' '.repeat(block.indent)}${block.marker} `}</Text>
|
||||
<Box flexGrow={1}>
|
||||
<Inline spans={block.spans} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
case 'quote':
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">{'| '}</Text>
|
||||
<Text dimColor italic>
|
||||
<Inline spans={block.spans} />
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
case 'code':
|
||||
return <CodeBlock language={block.language} lines={block.lines} />;
|
||||
case 'rule':
|
||||
return <Text dimColor>{'-'.repeat(Math.max(4, Math.min(width, 60)))}</Text>;
|
||||
case 'blank':
|
||||
return <Text> </Text>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders agent output as styled terminal markdown.
|
||||
*
|
||||
* Parsing happens here rather than in the transcript because a partial stream is
|
||||
* re-parsed on every flush; an unclosed fence simply renders as a code block that
|
||||
* grows, which is what a reader expects while text is still arriving.
|
||||
*/
|
||||
export function Markdown({ text, width = 80 }: { text: string; width?: number }) {
|
||||
const blocks = parseMarkdown(text);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{blocks.map((b, i) => (
|
||||
<BlockView key={i} block={b} width={width} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** One line of inline-styled markdown, for labels and summaries. */
|
||||
export function InlineMarkdown({ text }: { text: string }) {
|
||||
return <Inline spans={parseInline(text)} />;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import TextInput from 'ink-text-input';
|
||||
import Spinner from 'ink-spinner';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import type { Config, ProviderName } from '../config';
|
||||
import { fetchModels, PRESETS, type ProviderPreset } from '../providers';
|
||||
|
||||
export type OnboardResult = {
|
||||
presetId: string;
|
||||
provider: ProviderName;
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
type Step =
|
||||
| { name: 'pick-provider' }
|
||||
| { name: 'base-url'; preset: ProviderPreset }
|
||||
| { name: 'api-key'; preset: ProviderPreset; baseURL: string }
|
||||
| { name: 'loading'; preset: ProviderPreset; baseURL: string; apiKey: string }
|
||||
| { name: 'pick-model'; preset: ProviderPreset; baseURL: string; apiKey: string; models: string[]; warning?: string }
|
||||
| { name: 'type-model'; preset: ProviderPreset; baseURL: string; apiKey: string; warning?: string };
|
||||
|
||||
const mask = (key: string) => (key.length <= 8 ? '*'.repeat(key.length) : `${key.slice(0, 4)}...${key.slice(-4)}`);
|
||||
|
||||
const MANUAL_ENTRY = '__type_it__';
|
||||
|
||||
/**
|
||||
* Provider onboarding: pick a preset, supply a key, then choose a model from the
|
||||
* server's own /models list. Rendered in place of the prompt input, so it owns
|
||||
* the keyboard while open.
|
||||
*/
|
||||
export function Onboard({
|
||||
current,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
current: Config;
|
||||
onDone: (result: OnboardResult) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<Step>({ name: 'pick-provider' });
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) onCancel();
|
||||
},
|
||||
{ isActive: step.name !== 'loading' },
|
||||
);
|
||||
|
||||
const loadModels = useCallback(
|
||||
async (preset: ProviderPreset, baseURL: string, apiKey: string) => {
|
||||
setStep({ name: 'loading', preset, baseURL, apiKey });
|
||||
const { models, warning } = await fetchModels({ ...preset, baseURL }, apiKey);
|
||||
setDraft('');
|
||||
if (models.length === 0) {
|
||||
setStep({ name: 'type-model', preset, baseURL, apiKey, ...(warning ? { warning } : {}) });
|
||||
} else {
|
||||
setStep({ name: 'pick-model', preset, baseURL, apiKey, models, ...(warning ? { warning } : {}) });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const afterBaseUrl = useCallback(
|
||||
(preset: ProviderPreset, baseURL: string) => {
|
||||
const fromEnv = preset.envKey ? process.env[preset.envKey] : undefined;
|
||||
const key = preset.keyless ? 'local' : (fromEnv ?? '');
|
||||
if (key) return void loadModels(preset, baseURL, key);
|
||||
setDraft('');
|
||||
setStep({ name: 'api-key', preset, baseURL });
|
||||
},
|
||||
[loadModels],
|
||||
);
|
||||
|
||||
const pickProvider = useCallback(
|
||||
(preset: ProviderPreset) => {
|
||||
if (preset.baseURL) return afterBaseUrl(preset, preset.baseURL);
|
||||
setDraft('');
|
||||
setStep({ name: 'base-url', preset });
|
||||
},
|
||||
[afterBaseUrl],
|
||||
);
|
||||
|
||||
switch (step.name) {
|
||||
case 'pick-provider': {
|
||||
const items = PRESETS.map((p) => ({
|
||||
key: p.id,
|
||||
label: p.id === current.presetId ? `${p.label} (current)` : p.label,
|
||||
value: p.id,
|
||||
}));
|
||||
return (
|
||||
<Frame title="Choose a provider" hint="up/down to move, enter to select, esc to cancel">
|
||||
<SelectInput
|
||||
items={items}
|
||||
limit={12}
|
||||
initialIndex={Math.max(
|
||||
0,
|
||||
PRESETS.findIndex((p) => p.id === (current.presetId ?? current.provider)),
|
||||
)}
|
||||
onSelect={(item) => {
|
||||
const preset = PRESETS.find((p) => p.id === item.value);
|
||||
if (preset) pickProvider(preset);
|
||||
}}
|
||||
/>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
case 'base-url':
|
||||
return (
|
||||
<Frame title={`${step.preset.label}: endpoint URL`} hint="e.g. https://host/v1 - enter to continue">
|
||||
<Row label="URL">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) => v.trim() && afterBaseUrl(step.preset, v.trim())}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'api-key':
|
||||
return (
|
||||
<Frame
|
||||
title={`${step.preset.label}: API key`}
|
||||
hint={`stored in the shiro config file${step.preset.envKey ? `, or set ${step.preset.envKey} instead` : ''}`}
|
||||
>
|
||||
<Row label="key">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
mask="*"
|
||||
onSubmit={(v) => v.trim() && void loadModels(step.preset, step.baseURL, v.trim())}
|
||||
placeholder={step.preset.keyHint ?? 'paste it here'}
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'loading':
|
||||
return (
|
||||
<Frame title={`${step.preset.label}: fetching models`} hint={step.baseURL}>
|
||||
<Text color="yellow">
|
||||
<Spinner type="dots" /> <Text dimColor>GET {step.baseURL}/models</Text>
|
||||
</Text>
|
||||
</Frame>
|
||||
);
|
||||
|
||||
case 'pick-model': {
|
||||
const items = [
|
||||
...step.models.map((m) => ({ key: m, label: m, value: m })),
|
||||
{ key: MANUAL_ENTRY, label: 'type a model id myself...', value: MANUAL_ENTRY },
|
||||
];
|
||||
return (
|
||||
<Frame
|
||||
title={`${step.preset.label}: choose a model`}
|
||||
hint={`${step.models.length} models - key ${mask(step.apiKey)}`}
|
||||
warning={step.warning}
|
||||
>
|
||||
<SelectInput
|
||||
items={items}
|
||||
limit={10}
|
||||
initialIndex={Math.max(0, step.models.indexOf(current.model))}
|
||||
onSelect={(item) => {
|
||||
if (item.value === MANUAL_ENTRY) {
|
||||
setDraft('');
|
||||
setStep({ name: 'type-model', preset: step.preset, baseURL: step.baseURL, apiKey: step.apiKey });
|
||||
return;
|
||||
}
|
||||
onDone({
|
||||
presetId: step.preset.id,
|
||||
provider: step.preset.kind,
|
||||
baseURL: step.baseURL,
|
||||
apiKey: step.apiKey,
|
||||
model: item.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
case 'type-model':
|
||||
return (
|
||||
<Frame title={`${step.preset.label}: model id`} hint="enter to finish, esc to cancel" warning={step.warning}>
|
||||
<Row label="model">
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={(v) =>
|
||||
v.trim() &&
|
||||
onDone({
|
||||
presetId: step.preset.id,
|
||||
provider: step.preset.kind,
|
||||
baseURL: step.baseURL,
|
||||
apiKey: step.apiKey,
|
||||
model: v.trim(),
|
||||
})
|
||||
}
|
||||
placeholder="model-id"
|
||||
/>
|
||||
</Row>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Frame({
|
||||
title,
|
||||
hint,
|
||||
warning,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
warning?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>
|
||||
{title}
|
||||
</Text>
|
||||
{hint && <Text dimColor>{hint}</Text>}
|
||||
{warning && <Text color="yellow">could not list models: {warning}</Text>}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="cyan">{label}: </Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import React from 'react';
|
||||
import { TODO_MARK, type Todo } from '../notebook';
|
||||
import type { SubagentKind } from '../subagent';
|
||||
import { InlineMarkdown } from './Markdown';
|
||||
|
||||
const STATUS_COLOR: Record<Todo['status'], string | undefined> = {
|
||||
pending: undefined,
|
||||
in_progress: 'cyan',
|
||||
done: 'green',
|
||||
blocked: 'red',
|
||||
};
|
||||
|
||||
/** Task list with a progress bar, shown above the input while a list exists. */
|
||||
export function TodoPanel({ todos, width = 40 }: { todos: Todo[]; width?: number }) {
|
||||
const done = todos.filter((t) => t.status === 'done').length;
|
||||
const blocked = todos.filter((t) => t.status === 'blocked').length;
|
||||
const filled = todos.length === 0 ? 0 : Math.round((done / todos.length) * width);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1} marginBottom={1}>
|
||||
<Box>
|
||||
<Text bold>tasks </Text>
|
||||
<Text color="green">{'#'.repeat(filled)}</Text>
|
||||
<Text dimColor>{'.'.repeat(Math.max(0, width - filled))}</Text>
|
||||
<Text dimColor>{` ${done}/${todos.length}`}</Text>
|
||||
{blocked > 0 && <Text color="red">{` ${blocked} blocked`}</Text>}
|
||||
</Box>
|
||||
{todos.map((t, i) => (
|
||||
<Box key={i}>
|
||||
<Text color={STATUS_COLOR[t.status]}>{`${TODO_MARK[t.status]} `}</Text>
|
||||
<Text dimColor={t.status === 'done'} strikethrough={t.status === 'done'}>
|
||||
{t.content}
|
||||
</Text>
|
||||
{t.note && <Text dimColor>{` (${t.note})`}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export type SubagentView = {
|
||||
id: string;
|
||||
kind: SubagentKind;
|
||||
description: string;
|
||||
steps: { tool: string; summary: string }[];
|
||||
status: 'running' | 'done' | 'failed';
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<SubagentKind, string> = { explore: 'explore', review: 'review' };
|
||||
|
||||
/**
|
||||
* Live view of delegated work.
|
||||
*
|
||||
* A subagent can run for a minute over many files; without this the parent's spinner
|
||||
* is the only feedback and the user cannot tell progress from a hang.
|
||||
*/
|
||||
export function SubagentPanel({ agents }: { agents: SubagentView[] }) {
|
||||
if (agents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} marginBottom={1}>
|
||||
{agents.map((a) => (
|
||||
<Box key={a.id} flexDirection="column">
|
||||
<Box>
|
||||
{a.status === 'running' ? (
|
||||
<Text color="magenta">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={a.status === 'done' ? 'green' : 'red'}>{a.status === 'done' ? '*' : 'x'}</Text>
|
||||
)}
|
||||
<Text bold>{` ${KIND_LABEL[a.kind]}`}</Text>
|
||||
<Text>{`: ${a.description}`}</Text>
|
||||
<Text dimColor>{` ${a.steps.length} step${a.steps.length === 1 ? '' : 's'}`}</Text>
|
||||
</Box>
|
||||
{a.steps.slice(-3).map((s, i) => (
|
||||
<Text key={i} dimColor>
|
||||
{` ${s.tool}(${s.summary.slice(0, 60)})`}
|
||||
</Text>
|
||||
))}
|
||||
{a.error && <Text color="red">{` ${a.error}`}</Text>}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Live tail of a running shell command. */
|
||||
export function OutputPanel({ text, lines = 8 }: { text: string; lines?: number }) {
|
||||
if (text.length === 0) return null;
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{text
|
||||
.split('\n')
|
||||
.slice(-lines)
|
||||
.map((l, i) => (
|
||||
<Text key={i} dimColor>
|
||||
{` | ${l}`}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Status line under the transcript: model, agent, thinking, context, spend. */
|
||||
export function StatusBar({
|
||||
model,
|
||||
agent,
|
||||
thinking,
|
||||
contextTokens,
|
||||
cost,
|
||||
toolCount,
|
||||
}: {
|
||||
model: string;
|
||||
agent: string;
|
||||
thinking: string;
|
||||
contextTokens: number;
|
||||
cost: string;
|
||||
toolCount: number;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>{`${model} `}</Text>
|
||||
<Text color="cyan">{agent}</Text>
|
||||
<Text dimColor>{`/${thinking} ${toolCount} tools ~${contextTokens} ctx ${cost}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export type PanelLine = { label: string; value: string };
|
||||
|
||||
/** Bordered popup for a command's output, e.g. /skills or /cost. */
|
||||
export function InfoPanel({ title, hint, lines }: { title: string; hint?: string; lines: PanelLine[] | string }) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1}>
|
||||
<Text color="cyan" bold>
|
||||
{title}
|
||||
</Text>
|
||||
{hint && <Text dimColor>{hint}</Text>}
|
||||
{typeof lines === 'string' ? (
|
||||
<InlineMarkdown text={lines} />
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<Box key={i}>
|
||||
<Text color="gray">{l.label.padEnd(14)}</Text>
|
||||
<Text>{l.value}</Text>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Text, useInput } from 'ink';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export type PromptInputProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
placeholder?: string;
|
||||
focus?: boolean;
|
||||
mask?: string;
|
||||
/** Newest-last list of previously submitted prompts, walked by up/down. */
|
||||
history?: readonly string[];
|
||||
/** Intercept a key before the input consumes it. Return true to swallow it. */
|
||||
onKey?: (input: string, key: KeyLike) => boolean;
|
||||
};
|
||||
|
||||
type KeyLike = {
|
||||
upArrow: boolean;
|
||||
downArrow: boolean;
|
||||
leftArrow: boolean;
|
||||
rightArrow: boolean;
|
||||
return: boolean;
|
||||
escape: boolean;
|
||||
tab: boolean;
|
||||
backspace: boolean;
|
||||
delete: boolean;
|
||||
ctrl: boolean;
|
||||
meta: boolean;
|
||||
home?: boolean;
|
||||
end?: boolean;
|
||||
};
|
||||
|
||||
const INVERSE_ON = '\u001B[7m';
|
||||
const INVERSE_OFF = '\u001B[27m';
|
||||
const invert = (s: string) => `${INVERSE_ON}${s}${INVERSE_OFF}`;
|
||||
|
||||
/**
|
||||
* Text input with a real cursor and shell-style history recall.
|
||||
*
|
||||
* ink-text-input cannot do this: it discards up/down before its own handler and
|
||||
* only ever shrinks its internal cursor offset, so an externally driven value
|
||||
* leaves the cursor stranded. Owning the cursor here also gives us home/end and
|
||||
* ctrl-a/e/k/u/w for free.
|
||||
*/
|
||||
export function PromptInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = '',
|
||||
focus = true,
|
||||
mask,
|
||||
history = [],
|
||||
onKey,
|
||||
}: PromptInputProps) {
|
||||
const [cursor, setCursor] = useState(value.length);
|
||||
// -1 means "editing a fresh line"; 0+ indexes back from the newest entry.
|
||||
const [recall, setRecall] = useState(-1);
|
||||
const [stash, setStash] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setCursor((c) => Math.min(c, value.length));
|
||||
}, [value]);
|
||||
|
||||
const set = (next: string, nextCursor = next.length) => {
|
||||
onChange(next);
|
||||
setCursor(Math.max(0, Math.min(nextCursor, next.length)));
|
||||
};
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (onKey?.(input, key as KeyLike)) return;
|
||||
|
||||
if (key.return) {
|
||||
setRecall(-1);
|
||||
setStash('');
|
||||
setCursor(0);
|
||||
onSubmit(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.upArrow || key.downArrow) {
|
||||
if (history.length === 0) return;
|
||||
if (key.upArrow) {
|
||||
const next = Math.min(recall + 1, history.length - 1);
|
||||
if (recall === -1) setStash(value);
|
||||
setRecall(next);
|
||||
set(history[history.length - 1 - next] ?? value);
|
||||
} else {
|
||||
const next = recall - 1;
|
||||
setRecall(next);
|
||||
set(next < 0 ? stash : (history[history.length - 1 - next] ?? ''));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.leftArrow) return setCursor((c) => Math.max(0, c - 1));
|
||||
if (key.rightArrow) return setCursor((c) => Math.min(value.length, c + 1));
|
||||
if (key.home || (key.ctrl && input === 'a')) return setCursor(0);
|
||||
if (key.end || (key.ctrl && input === 'e')) return setCursor(value.length);
|
||||
|
||||
if (key.ctrl && input === 'k') return set(value.slice(0, cursor), cursor);
|
||||
if (key.ctrl && input === 'u') return set(value.slice(cursor), 0);
|
||||
if (key.ctrl && input === 'w') {
|
||||
const upto = value.slice(0, cursor);
|
||||
const trimmed = upto.replace(/\S+\s*$/, '');
|
||||
return set(trimmed + value.slice(cursor), trimmed.length);
|
||||
}
|
||||
|
||||
if (key.backspace || key.delete) {
|
||||
if (cursor === 0) return;
|
||||
return set(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1);
|
||||
}
|
||||
|
||||
// Ignore remaining control sequences; a paste arrives as one multi-char input.
|
||||
if (!input || key.tab || key.escape || key.meta || key.ctrl) return;
|
||||
set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
|
||||
},
|
||||
{ isActive: focus },
|
||||
);
|
||||
|
||||
if (value.length === 0) {
|
||||
if (!placeholder) return <Text>{focus ? invert(' ') : ' '}</Text>;
|
||||
return (
|
||||
<Text dimColor>
|
||||
{focus ? invert(placeholder.slice(0, 1)) : placeholder.slice(0, 1)}
|
||||
{placeholder.slice(1)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const shown = mask ? mask.repeat(value.length) : value;
|
||||
if (!focus) return <Text>{shown}</Text>;
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{shown.slice(0, cursor)}
|
||||
{invert(shown.slice(cursor, cursor + 1) || ' ')}
|
||||
{shown.slice(cursor + 1)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export type { KeyLike };
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Single source of truth for the version.
|
||||
*
|
||||
* `bun build --compile` does not embed package.json, so reading it at runtime
|
||||
* fails inside the shipped binary. A constant is compiled in and always correct.
|
||||
* `scripts/release.ts` checks it against the release tag so the two cannot drift.
|
||||
*/
|
||||
export const VERSION = '0.1.0-beta.1';
|
||||
|
||||
/** What `--version` prints: enough to identify a build from a bug report. */
|
||||
export function versionLine(): string {
|
||||
return [
|
||||
`shiro-neko ${VERSION}`,
|
||||
`bun ${Bun.version}`,
|
||||
`${process.platform}-${process.arch}`,
|
||||
import.meta.path.startsWith('/$bunfs/') || import.meta.path.includes('~BUN') ? 'compiled' : 'source',
|
||||
].join(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user