diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc40c81..d1e2789 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,11 +8,19 @@ on: jobs: build: - runs-on: ubuntu-latest - + runs-on: ${{ matrix.runner }} strategy: + fail-fast: false matrix: - node-version: [18.x, 20.x, 22.x] + include: + - runner: ubuntu-latest + node-version: 18.x + - runner: ubuntu-latest + node-version: 20.x + - runner: ubuntu-latest + node-version: 22.x + - runner: ubuntu-24.04-arm + node-version: 20.x steps: - name: Checkout repository @@ -39,6 +47,9 @@ jobs: run: pnpm test continue-on-error: true + - name: Security audit + run: pnpm audit --audit-level=high + lint: runs-on: ubuntu-latest diff --git a/DOCS.md b/DOCS.md index e781038..bebe0c7 100644 --- a/DOCS.md +++ b/DOCS.md @@ -21,7 +21,8 @@ Version: **0.6.1** 7. [Memory System](#memory-system) 8. [Provider Recipes](#provider-recipes) 9. [Sandbox / CI Usage](#sandbox--ci-usage) -10. [Tips & Tricks](#tips--tricks) +10. [Platforms and devices](#platforms-and-devices) +11. [Tips & Tricks](#tips--tricks) --- @@ -92,6 +93,18 @@ Verify config: xibecode config --show ``` +### Cost-saving (economy) mode + +Reduce API cost by using a cheaper/smaller model and lower iteration and token caps: + +```bash +xibecode config --set-cost-mode economy +xibecode config --set-economy-model claude-haiku-4-5-20251015 +``` + +- **Economy mode**: When enabled, `run`, `run-pr`, and `chat` use the economy model (if set) and cap max iterations at the economy limit (default 50). Use for high-volume or non-critical runs. +- **Per-command override**: `xibecode run --cost-mode economy "task"` or `xibecode run-pr --cost-mode normal "task"` to override config for one run. + --- ## Commands Reference @@ -119,6 +132,9 @@ xibecode run "Preview only" --dry-run | `--provider ` | `anthropic` or `openai` | | `-d, --max-iterations ` | Max agent iterations (default: `150`, `0` = unlimited) | | `-v, --verbose` | Show detailed tool call logs | +| `--cost-mode ` | `normal` or `economy` (cheaper model, lower iteration cap) | +| `--plan-first` | Force a strategic plan (one-shot, no tools) before execution (AX-lite) | +| `--mindset-adaptive` | Enable CoM-style reasoning mindsets (convergent/divergent/algorithmic) | | `--dry-run` | Preview changes without writing | | `--changed-only` | Focus only on git-changed files | | `--non-interactive` | Suppress auto-exit (for programmatic embedding) | @@ -169,6 +185,9 @@ prompt | `--provider ` | `anthropic` or `openai` | | `-d, --max-iterations ` | Max iterations (default: `150`) | | `-v, --verbose` | Show detailed logs including git operations | +| `--cost-mode ` | `normal` or `economy` (cheaper model, lower caps) | +| `--plan-first` | Force a strategic plan before execution (AX-lite) | +| `--mindset-adaptive` | Enable CoM-style reasoning mindsets | | `--branch ` | Override auto-generated branch name | | `--title ` | Override PR title | | `--draft` | Open PR as draft | @@ -389,6 +408,22 @@ Or add directly to `.xibecode/memory.md`: - Run `pnpm test` to execute the test suite ``` +### Session memory (this run) + +Within a single `run` or `run-pr`, the agent keeps **session memory**: tool attempts, failures, and learnings. A compact summary is injected into the system prompt so the agent avoids repeating the same mistakes. Session data is persisted under `.xibecode/sessions/` and recent learnings can be loaded into the next run. + +### Context pruning + +Before each run, the CLI scores project files by relevance to the task (keyword match). The top N file paths are suggested to the agent so it can prioritize `get_context` and `read_file`. This reduces noise and token use on large repos. + +- **Config**: `maxContextFiles` (default `40`). Set to `0` to disable (edit config file or use `config.set('maxContextFiles', 0)`). + +### Multi-model routing + +You can use a different model for strategic (planning) vs tactical/operational (execution) steps. Set `planningModel` and/or `executionModel` in config. When `--plan-first` is used, the strategic plan uses `planningModel` if set; the rest of the run uses `executionModel` if set, otherwise the default `model`. +- **Behavior**: Keyword-based scoring; extensions include `.ts`, `.tsx`, `.js`, `.py`, `.go`, `.md`, etc. `node_modules`, `.git`, `dist` are ignored. +- **PKG-style (optional)**: Set `usePkgStyleContext: true` in config to augment with AST/code-graph relevance (TypeScript/JavaScript). Helps on large codebases. + --- ## Provider Recipes @@ -488,6 +523,28 @@ xibecode run "your task" xibecode run-pr "Fix all TypeScript type errors" --skip-tests ``` +### Agent-triggered execution and safety + +Agent-triggered shell commands (`run_command`) run with the **same user and privileges** as the XibeCode process. There is no sudo or elevation. For untrusted code or third-party repos, run XibeCode inside a **container or sandbox** (e.g. Docker, E2B, or a disposable VM). + +- **Path and URL validation**: File paths used by tools are resolved under the working directory; paths that escape it (e.g. `../etc/passwd`) are rejected. The `fetch_url` tool only allows `http`/`https` URLs and blocks local/private addresses by default to reduce SSRF risk. +- **Blocked commands**: The built-in safety layer blocks obviously dangerous commands (e.g. `rm -rf /`, fork bombs). See `SECURITY.md` and `src/utils/safety.ts` for details. + +--- + +## Platforms and devices + +XibeCode is built to run across common platforms and device types. + +| Surface | Platforms | Notes | +|--------|------------|--------| +| **CLI** | Linux (x64, ARM64), macOS (Intel, Apple Silicon), Windows (x64, ARM64) | Node.js 18+. ARM64 includes Raspberry Pi and ARM servers. | +| **WebUI** | Any browser | Responsive, mobile-friendly layout; touch-friendly. Optional PWA/installable. | +| **Desktop (Electron)** | Windows (x64/ARM64), macOS (Intel/Apple Silicon), Linux (x64/ARM64) | Download from [Releases](https://github.com/iotserver24/xibecode/releases). AppImage/deb for arm64 on Linux. | +| **Headless / embedded** | Servers, Docker, Raspberry Pi | Use `xibecode run` or `xibecode run-pr` with env-based config (e.g. `ANTHROPIC_API_KEY`, `xibecode config`). No TUI required; suitable for CI, cron, or an agent daemon. | + +CI runs on Linux x64 and ARM64 (where available) to validate the CLI. For headless usage, set your API key and endpoint via environment or config, then run tasks non-interactively. + --- ## Tips & Tricks diff --git a/README.md b/README.md index e570c7f..9d61a55 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ npm link - Node.js 18+ - API key from Anthropic or OpenAI +**Platforms:** CLI and WebUI run on Linux (x64, ARM64), macOS (Intel, Apple Silicon), and Windows. For servers, Docker, or Raspberry Pi, use headless runs: `xibecode run` / `xibecode run-pr` with env-based config (no TUI). See [DOCS.md](DOCS.md#platforms-and-devices) for the full device matrix. + ## Quick Start ```bash @@ -211,6 +213,7 @@ Options: - `--provider <provider>` `anthropic` or `openai` - `-d, --max-iterations <number>` default `150` (`0` = unlimited) - `-v, --verbose` +- `--cost-mode <mode>` `normal` or `economy` (use cheaper model and lower iteration caps to save API cost) - `--dry-run` - `--changed-only` @@ -251,6 +254,7 @@ Options: - `--provider <provider>` `anthropic` or `openai` - `-d, --max-iterations <number>` default `150` (`0` = unlimited) - `-v, --verbose` +- `--cost-mode <mode>` `normal` or `economy` (save API cost) - `--branch <name>` override generated branch name - `--title <title>` override PR title - `--draft` open PR as draft @@ -274,6 +278,7 @@ Options: - `-b, --base-url <url>` - `-k, --api-key <key>` - `--provider <provider>` +- `--cost-mode <mode>` `normal` or `economy` - `--theme <theme>` - `--session <id>` @@ -281,7 +286,9 @@ Options: Manage saved config: -- `--set-key`, `--set-url`, `--set-model` +- `--set-key`, `--set-url`, `--set-model`, `--set-provider` +- `--set-cost-mode <mode>` set default cost mode: `normal` or `economy` +- `--set-economy-model <model>` model to use when cost mode is `economy` - `--show`, `--reset` - MCP helpers: `--list-mcp-servers`, `--add-mcp-server`, `--remove-mcp-server` @@ -379,7 +386,7 @@ Click the ⚙️ Settings button to configure: - **Markdown Rendering** - Code blocks, bold, italic, lists, links - **Tool Execution** - Shows each tool call with status (running/done/failed) - **Thinking Indicator** - Spinner while AI is processing -- **Responsive Design** - Works on mobile and desktop +- **Responsive Design** - Mobile-friendly layout and touch-friendly controls; works on phones, tablets, and desktop - **Real-time Streaming** - See responses as they're generated ## AI Test Generation diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8667057 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# Security + +## Secrets + +- API keys and env-based secrets are never logged or included in error messages. Display config uses masked values (e.g. `sk-ant-...`). +- Store keys in environment variables or the local config file; avoid passing them on the command line in shared environments. + +## Agent-triggered execution (sandbox) + +- Commands executed via the agent’s `run_command` tool run with the **same user and privileges** as the XibeCode process. There is no sandboxing of shell commands by default. +- **Recommendation**: For untrusted code or third-party repositories, run XibeCode inside a container (e.g. Docker) or a dedicated sandbox (e.g. E2B) so that agent-triggered commands cannot affect the host. +- The `SafetyChecker` in `src/utils/safety.ts` blocks obviously dangerous commands (e.g. `rm -rf /`, fork bombs). This is a best-effort filter, not a full sandbox. + +## Input validation + +- **File paths**: All file tools resolve paths under the working directory. Paths that escape the workspace (path traversal) are rejected via `sanitizePath()` in `src/utils/safety.ts`. +- **URLs**: The `fetch_url` tool only allows `http:` and `https:` URLs. Local and private addresses (localhost, 127.0.0.1, 192.168.x.x, 10.x.x.x, .local) are rejected by default to reduce SSRF risk. See `sanitizeUrl()` in `src/utils/safety.ts`. + +## Dependencies + +- CI runs `pnpm audit --audit-level=high`. High and critical vulnerabilities are treated as blocking. Fix or mitigate before merging. + +## New stack (Confucius-aligned features) + +- **Meta-agent / synthesized tools**: Session-scoped tools registered via `synthesize_tool` run the same sandbox as `run_command`; `SafetyChecker` and blocked-command rules apply. Scripts are not elevated. +- **Session memory**: Stored under `.xibecode/sessions/`; no API keys or secrets are written. Failure/learning summaries are plain text only. +- **PKG-style context**: Uses the local CodeGraph (AST) only; no external network or knowledge APIs. +- **Economy and multi-model routing**: When cost mode is economy, planning and execution model selection still use the economy model when configured. Self-correction retries in `run-pr` use the same config, so token/iteration caps apply to each attempt. diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 25724ac..92292a9 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -24,6 +24,7 @@ interface ChatOptions { baseUrl?: string; apiKey?: string; provider?: string; + costMode?: string; theme?: string; session?: string; noWebui?: boolean; @@ -72,7 +73,8 @@ export async function chatCommand(options: ChatOptions) { process.exit(1); } - const model = options.model || config.getModel(); + const useEconomy = (options.costMode || config.getCostMode()) === 'economy'; + const model = options.model || config.getModel(useEconomy); const baseUrl = options.baseUrl || config.getBaseUrl(); let currentProvider: ProviderType | undefined = (options.provider as ProviderType | undefined) || config.get('provider'); diff --git a/src/commands/config.ts b/src/commands/config.ts index a8a3582..d7460c5 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -9,6 +9,8 @@ interface ConfigOptions { setUrl?: string; setModel?: string; setProvider?: string; + setCostMode?: string; + setEconomyModel?: string; show?: boolean; reset?: boolean; listMcpServers?: boolean; @@ -66,6 +68,23 @@ export async function configCommand(options: ConfigOptions) { return; } + if (options.setCostMode) { + const mode = options.setCostMode.toLowerCase(); + if (mode !== 'normal' && mode !== 'economy') { + ui.error(`Invalid cost mode "${options.setCostMode}". Use: normal or economy`); + process.exit(1); + } + config.set('costMode', mode as 'normal' | 'economy'); + ui.success(`Cost mode set to: ${mode}`); + return; + } + + if (options.setEconomyModel) { + config.set('economyModel', options.setEconomyModel); + ui.success(`Economy model set to: ${options.setEconomyModel}`); + return; + } + if (options.listMcpServers) { const servers = await config.getMCPServers(); const serverNames = Object.keys(servers); diff --git a/src/commands/run-pr.ts b/src/commands/run-pr.ts index 8c14f8d..25f06e2 100644 --- a/src/commands/run-pr.ts +++ b/src/commands/run-pr.ts @@ -8,6 +8,8 @@ import { MCPClientManager } from '../core/mcp-client.js'; import { EnhancedUI } from '../ui/enhanced-tui.js'; import { ConfigManager } from '../utils/config.js'; import { NeuralMemory } from '../core/memory.js'; +import { SessionMemory } from '../core/session-memory.js'; +import { pruneContext } from '../core/context-pruner.js'; import { SkillManager } from '../core/skills.js'; import chalk from 'chalk'; @@ -21,6 +23,9 @@ interface RunPrOptions { provider?: string; maxIterations: string; verbose: boolean; + costMode?: string; + planFirst?: boolean; + mindsetAdaptive?: boolean; branch?: string; title?: string; draft?: boolean; @@ -246,17 +251,23 @@ export async function runPrCommand(prompt: string | undefined, options: RunPrOpt } // ── Config ─────────────────────────────────────────────────────────────── - const model = options.model || config.getModel(); + const costMode = (options.costMode || config.getCostMode()) as 'normal' | 'economy'; + const useEconomy = costMode === 'economy'; + const model = options.model || config.getModel(useEconomy); const baseUrl = options.baseUrl || config.getBaseUrl(); const provider = (options.provider as 'anthropic' | 'openai' | undefined) || config.get('provider'); - const parsedIterations = parseInt(options.maxIterations); - const maxIterations = parsedIterations > 0 ? parsedIterations : 150; + let parsedIterations = parseInt(options.maxIterations); + if (parsedIterations <= 0) parsedIterations = 150; + const maxIterations = useEconomy + ? Math.min(parsedIterations, config.getEconomyMaxIterations()) + : parsedIterations; const testCommandOverride = config.get('testCommandOverride'); // Diagnostic — always print resolved config so misconfiguration is obvious const maskedKey = apiKey ? apiKey.slice(0, 8) + '...' + apiKey.slice(-4) : 'NOT SET'; + console.log(chalk.dim(' cost mode ') + chalk.cyan(useEconomy ? 'economy' : 'normal')); console.log(chalk.dim(' provider ') + chalk.cyan(provider ?? 'auto-detect')); console.log(chalk.dim(' model ') + chalk.cyan(model)); console.log(chalk.dim(' base url ') + chalk.cyan(baseUrl ?? 'provider default')); @@ -307,6 +318,14 @@ export async function runPrCommand(prompt: string | undefined, options: RunPrOpt memory, skillManager, }); + const sessionMemory = new SessionMemory(cwd); + await sessionMemory.loadPreviousLearnings().catch(() => {}); + + const maxContextFiles = config.getMaxContextFiles(); + const contextHintFiles = maxContextFiles > 0 + ? await pruneContext(cwd, finalPrompt, { maxFiles: maxContextFiles, usePkgStyleContext: config.getUsePkgStyleContext() }).catch(() => []) + : []; + const agent = new EnhancedAgent( { apiKey, @@ -317,6 +336,12 @@ export async function runPrCommand(prompt: string | undefined, options: RunPrOpt mode: 'agent', provider: provider as any, customProviderFormat: config.get('customProviderFormat'), + planFirst: options.planFirst ?? false, + mindsetAdaptive: options.mindsetAdaptive ?? false, + sessionMemory, + contextHintFiles, + planningModel: config.getPlanningModel(), + executionModel: config.getExecutionModel(), }, provider as any ); @@ -370,63 +395,121 @@ export async function runPrCommand(prompt: string | undefined, options: RunPrOpt } }); - // ── Run the agent ───────────────────────────────────────────────────────── + // ── Self-correction loop: run agent, then verify; on test failure retry up to 2 times ── + const maxSelfCorrectRetries = 2; + let attempt = 0; + let testPassed = false; + let lastTestError = ''; + let stats = { iterations: 0, filesChanged: 0, toolCalls: 0, changedFiles: [] as string[] }; + let currentAgent = agent; + try { - await agent.run(finalPrompt, toolExecutor.getTools(), toolExecutor); + while (attempt <= maxSelfCorrectRetries) { + const isRetry = attempt > 0; + const prompt = isRetry + ? `[Self-correction] The previous run's test suite failed. Fix the failures and ensure tests pass.\n\nTest output:\n${lastTestError.slice(0, 2000)}\n\nOriginal task: ${finalPrompt}` + : finalPrompt; + + if (isRetry) { + sessionMemory.recordLearning(`Tests failed (attempt ${attempt}): ${lastTestError.slice(0, 200)}`); + const retryContextHintFiles = maxContextFiles > 0 + ? await pruneContext(cwd, 'fix failing tests ' + finalPrompt, { maxFiles: maxContextFiles, usePkgStyleContext: config.getUsePkgStyleContext() }).catch(() => []) + : []; + currentAgent = new EnhancedAgent( + { + apiKey, + baseUrl, + model, + maxIterations, + verbose: options.verbose, + mode: 'agent', + provider: provider as any, + customProviderFormat: config.get('customProviderFormat'), + planFirst: false, + mindsetAdaptive: options.mindsetAdaptive ?? false, + sessionMemory, + contextHintFiles: retryContextHintFiles, + planningModel: config.getPlanningModel(), + executionModel: config.getExecutionModel(), + }, + provider as any + ); + (currentAgent as any).memory = memory; + ui.warning(`Self-correction retry ${attempt}/${maxSelfCorrectRetries} — re-running agent with test failure context.`); + } - const stats = agent.getStats(); - const duration = Date.now() - startTime; + await currentAgent.run(prompt, toolExecutor.getTools(), toolExecutor); + await sessionMemory.persist(); - ui.completionSummary({ - iterations: stats.iterations, - duration, - filesChanged: stats.filesChanged, - toolCalls: stats.toolCalls, - }); + stats = currentAgent.getStats(); + const duration = Date.now() - startTime; - if (stats.changedFiles.length > 0) { - console.log(chalk.white(' 📝 Files modified:\n')); - stats.changedFiles.forEach(file => { - console.log(chalk.gray(' • ') + chalk.white(file)); + ui.completionSummary({ + iterations: stats.iterations, + duration, + filesChanged: stats.filesChanged, + toolCalls: stats.toolCalls, }); - console.log(''); - } - // ── Check for actual git changes ───────────────────────────────────── - const changedFiles = await getChangedFiles(cwd); - if (changedFiles.length === 0) { - ui.warning('No git changes detected after the agent run. Skipping branch/PR creation.'); - process.exit(0); - } + if (stats.changedFiles.length > 0) { + console.log(chalk.white(' 📝 Files modified:\n')); + stats.changedFiles.forEach((file: string) => { + console.log(chalk.gray(' • ') + chalk.white(file)); + }); + console.log(''); + } + + // ── Check for actual git changes ───────────────────────────────────── + const changedFiles = await getChangedFiles(cwd); + if (changedFiles.length === 0 && !isRetry) { + ui.warning('No git changes detected after the agent run. Skipping branch/PR creation.'); + process.exit(0); + } + if (changedFiles.length === 0 && isRetry) { + ui.warning('No git changes on retry. Aborting.'); + process.exit(1); + } - // ── Run tests / verification ───────────────────────────────────────── - if (!options.skipTests) { + // ── Run tests / verification ───────────────────────────────────────── + if (options.skipTests) { + testPassed = true; + break; + } const testCmd = testCommandOverride || await detectTestCommand(cwd); - if (testCmd) { - console.log(chalk.cyan(`\n Running verification: ${testCmd}\n`)); - try { - const { stdout: testOut, stderr: testErr } = await execAsync(testCmd, { - cwd, - timeout: 300_000, - }); - if (options.verbose) { - if (testOut) console.log(chalk.dim(testOut)); - if (testErr) console.log(chalk.dim(testErr)); - } - ui.info('Verification passed.'); - } catch (err: any) { - ui.error(`Verification failed — tests did not pass. Aborting PR creation.\n ${err.message}`); - if (options.verbose && err.stdout) console.log(chalk.dim(err.stdout)); - if (options.verbose && err.stderr) console.log(chalk.dim(err.stderr)); + if (!testCmd) { + ui.info('No test command detected, skipping verification.'); + testPassed = true; + break; + } + console.log(chalk.cyan(`\n Running verification: ${testCmd}\n`)); + try { + const { stdout: testOut, stderr: testErr } = await execAsync(testCmd, { + cwd, + timeout: 300_000, + }); + if (options.verbose) { + if (testOut) console.log(chalk.dim(testOut)); + if (testErr) console.log(chalk.dim(testErr)); + } + ui.info('Verification passed.'); + testPassed = true; + break; + } catch (err: any) { + lastTestError = [err.stdout, err.stderr].filter(Boolean).join('\n') || err.message; + if (options.verbose && err.stdout) console.log(chalk.dim(err.stdout)); + if (options.verbose && err.stderr) console.log(chalk.dim(err.stderr)); + attempt++; + if (attempt > maxSelfCorrectRetries) { + ui.error(`Verification failed after ${maxSelfCorrectRetries} retry(ies). Aborting PR creation.\n ${err.message}`); process.exit(1); } - } else { - ui.info('No test command detected, skipping verification.'); + ui.warning(`Verification failed. Starting self-correction retry ${attempt}/${maxSelfCorrectRetries}...`); } - } else { - ui.info('Test verification skipped (--skip-tests).'); } + const changedFiles = await getChangedFiles(cwd); + const duration = Date.now() - startTime; + // ── Detect base branch ─────────────────────────────────────────────── const baseBranch = await detectDefaultBase(cwd); ui.info(`Base branch: ${baseBranch}`); diff --git a/src/commands/run.ts b/src/commands/run.ts index a4a8d34..e26d712 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -8,6 +8,8 @@ import { ConfigManager } from '../utils/config.js'; import { PlanMode } from '../core/planMode.js'; import { TodoManager } from '../utils/todoManager.js'; import { NeuralMemory } from '../core/memory.js'; +import { SessionMemory } from '../core/session-memory.js'; +import { pruneContext } from '../core/context-pruner.js'; import { SkillManager } from '../core/skills.js'; import chalk from 'chalk'; @@ -20,6 +22,9 @@ interface RunOptions { provider?: string; maxIterations: string; verbose: boolean; + costMode?: string; + planFirst?: boolean; + mindsetAdaptive?: boolean; dryRun?: boolean; changedOnly?: boolean; nonInteractive?: boolean; @@ -62,17 +67,23 @@ export async function runCommand(prompt: string | undefined, options: RunOptions process.exit(1); } - // Get model and base URL - const model = options.model || config.getModel(); + // Cost mode: economy uses cheaper model and lower caps + const costMode = (options.costMode || config.getCostMode()) as 'normal' | 'economy'; + const useEconomy = costMode === 'economy'; + const model = options.model || config.getModel(useEconomy); const baseUrl = options.baseUrl || config.getBaseUrl(); const provider = (options.provider as 'anthropic' | 'openai' | undefined) || config.get('provider'); - const parsedIterations = parseInt(options.maxIterations); - const maxIterations = parsedIterations > 0 ? parsedIterations : 150; + let parsedIterations = parseInt(options.maxIterations); + if (parsedIterations <= 0) parsedIterations = 150; + const maxIterations = useEconomy + ? Math.min(parsedIterations, config.getEconomyMaxIterations()) + : parsedIterations; // Diagnostic — always print resolved config so misconfiguration is obvious const maskedKey = apiKey ? apiKey.slice(0, 8) + '...' + apiKey.slice(-4) : 'NOT SET'; + console.log(chalk.dim(' cost mode ') + chalk.cyan(useEconomy ? 'economy' : 'normal')); console.log(chalk.dim(' provider ') + chalk.cyan(provider ?? 'auto-detect')); console.log(chalk.dim(' model ') + chalk.cyan(model)); console.log(chalk.dim(' base url ') + chalk.cyan(baseUrl ?? 'provider default')); @@ -172,6 +183,14 @@ export async function runCommand(prompt: string | undefined, options: RunOptions memory, skillManager, }); + const sessionMemory = new SessionMemory(process.cwd()); + await sessionMemory.loadPreviousLearnings().catch(() => {}); + + const maxContextFiles = config.getMaxContextFiles(); + const contextHintFiles = maxContextFiles > 0 + ? await pruneContext(process.cwd(), effectivePrompt, { maxFiles: maxContextFiles, usePkgStyleContext: config.getUsePkgStyleContext() }).catch(() => []) + : []; + const agent = new EnhancedAgent( { apiKey, @@ -182,6 +201,12 @@ export async function runCommand(prompt: string | undefined, options: RunOptions mode: (options.mode as any) || 'agent', provider: provider as any, customProviderFormat: config.get('customProviderFormat'), + planFirst: options.planFirst ?? false, + mindsetAdaptive: options.mindsetAdaptive ?? false, + sessionMemory, + contextHintFiles, + planningModel: config.getPlanningModel(), + executionModel: config.getExecutionModel(), }, provider as any); // Inject memory into agent (we'll need to update Agent to accept it or just let it use its own? Better to share same instance) @@ -258,6 +283,7 @@ export async function runCommand(prompt: string | undefined, options: RunOptions // Run the agent try { await agent.run(effectivePrompt, toolExecutor.getTools(), toolExecutor); + await sessionMemory.persist(); const stats = agent.getStats(); const duration = Date.now() - startTime; diff --git a/src/core/agent.ts b/src/core/agent.ts index 4438fcc..05b735e 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -5,8 +5,15 @@ import * as fsSync from 'fs'; import { EventEmitter } from 'events'; import { AgentMode, MODE_CONFIG, ModeState, createModeState, transitionMode, ModeOrchestrator, parseModeRequest, stripModeRequests, parseTaskComplete, stripTaskComplete, ModeTransitionPolicy } from './modes.js'; import { NeuralMemory } from './memory.js'; +import { SessionMemory } from './session-memory.js'; import { PROVIDER_CONFIGS, ProviderType } from '../utils/config.js'; +/** Reasoning tier for hierarchical (AX-lite) behavior: strategic = plan only, tactical = per-step decisions, operational = tool use. */ +export type ReasoningTier = 'strategic' | 'tactical' | 'operational'; + +/** CoM-style reasoning mindset: influences system-prompt fragment for this turn. */ +export type ReasoningMindset = 'convergent' | 'divergent' | 'algorithmic'; + export interface AgentConfig { apiKey: string; baseUrl?: string; @@ -16,6 +23,18 @@ export interface AgentConfig { mode?: AgentMode; provider?: ProviderType; customProviderFormat?: 'openai' | 'anthropic'; + /** When true, force a strategic plan (one-shot, no tools) before tactical/operational execution. */ + planFirst?: boolean; + /** Optional session memory for this run (attempts, failures, learnings). */ + sessionMemory?: SessionMemory; + /** Optional list of file paths suggested as relevant to the task (context pruning). */ + contextHintFiles?: string[]; + /** Model for strategic tier (multi-model routing). */ + planningModel?: string; + /** Model for tactical/operational tier (multi-model routing). */ + executionModel?: string; + /** Enable mindset-adaptive reasoning (CoM-style: convergent/divergent/algorithmic). */ + mindsetAdaptive?: boolean; } export interface AgentEvent { @@ -147,7 +166,7 @@ export class EnhancedAgent extends EventEmitter { private messages: MessageParam[] = []; private loopDetector = new LoopDetector(); private thinkFilter = new ThinkTagFilter(); - private config: Required<AgentConfig> & { customProviderFormat: 'openai' | 'anthropic' }; + private config: Required<Omit<AgentConfig, 'sessionMemory' | 'contextHintFiles' | 'planningModel' | 'executionModel' | 'mindsetAdaptive'>> & { customProviderFormat: 'openai' | 'anthropic'; sessionMemory?: SessionMemory | null; contextHintFiles: string[]; planningModel?: string; executionModel?: string; mindsetAdaptive?: boolean }; private iterationCount = 0; private toolCallCount = 0; private filesChanged: Set<string> = new Set(); @@ -161,6 +180,14 @@ export class EnhancedAgent extends EventEmitter { private activeSkill: { name: string; instructions: string } | null = null; private memory: NeuralMemory; private injectedMessages: string[] = []; + /** Current reasoning tier (AX-lite): strategic = plan, tactical = step decisions, operational = tools. */ + private currentTier: ReasoningTier = 'tactical'; + /** When plan-first was used, the initial strategic plan text (for context). */ + private strategicPlanText: string = ''; + private sessionMemory: SessionMemory | null = null; + private contextHintFiles: string[] = []; + private mindsetAdaptive: boolean = false; + private currentMindset: ReasoningMindset = 'convergent'; public injectMessage(message: string): void { this.injectedMessages.push(message); @@ -200,7 +227,14 @@ export class EnhancedAgent extends EventEmitter { mode: config.mode ?? 'agent', provider: config.provider ?? this.detectProvider(config.model), customProviderFormat: config.customProviderFormat ?? 'openai', + planFirst: config.planFirst ?? false, + sessionMemory: config.sessionMemory, + contextHintFiles: config.contextHintFiles ?? [], + planningModel: config.planningModel, + executionModel: config.executionModel, + mindsetAdaptive: config.mindsetAdaptive ?? false, }; + this.mindsetAdaptive = this.config.mindsetAdaptive ?? false; // Initialize mode state and orchestrator this.modeState = createModeState(this.config.mode); @@ -212,6 +246,8 @@ export class EnhancedAgent extends EventEmitter { this.provider = providerOverride ?? config.provider ?? this.detectProvider(this.config.model); // Load project memory if it exists + this.sessionMemory = config.sessionMemory ?? null; + this.contextHintFiles = config.contextHintFiles ?? []; this.memory = new NeuralMemory(); this.memory.init().catch(console.error); @@ -237,6 +273,13 @@ export class EnhancedAgent extends EventEmitter { return 'openai'; } + /** Multi-model routing: use planning model for strategic tier, execution model for tactical/operational when set. */ + private getModelForTier(): string { + if (this.currentTier === 'strategic' && this.config.planningModel) return this.config.planningModel; + if ((this.currentTier === 'tactical' || this.currentTier === 'operational') && this.config.executionModel) return this.config.executionModel; + return this.config.model; + } + emit(event: AgentEvent['type'], data: any): boolean { return super.emit('event', { type: event, data }); } @@ -269,6 +312,41 @@ export class EnhancedAgent extends EventEmitter { this.emit('thinking', { message: 'Starting agent...' }); + // ─── Plan-first (AX-lite strategic tier): one-shot plan before execution ─── + if (this.config.planFirst) { + this.currentTier = 'strategic'; + this.emit('thinking', { message: 'Strategic planning (plan-first mode)...' }); + try { + const planResult = await this.callModel([]); + const planContent = planResult.message?.content; + let planText = ''; + if (Array.isArray(planContent)) { + for (const block of planContent) { + if (block.type === 'text' && typeof block.text === 'string') { + planText += block.text; + } + } + } else if (typeof planContent === 'string') { + planText = planContent; + } + planText = planText.trim() || 'Proceed step by step.'; + this.strategicPlanText = planText; + this.messages.push({ + role: 'assistant', + content: planResult.message?.content ?? planText, + }); + this.messages.push({ + role: 'user', + content: `[Strategic plan you created]\n\n${planText}\n\nNow execute this plan step by step. Use the available tools to implement each part.`, + }); + this.currentTier = 'tactical'; + this.emit('thinking', { message: 'Strategic plan complete; starting execution.' }); + } catch (err: any) { + this.emit('warning', { message: `Plan-first planning failed: ${err?.message ?? err}. Continuing without plan.` }); + this.currentTier = 'tactical'; + } + } + while (this.iterationCount < this.config.maxIterations) { this.iterationCount++; @@ -349,6 +427,15 @@ export class EnhancedAgent extends EventEmitter { // Check for mode change requests in text blocks for (const block of textBlocks) { + if (this.mindsetAdaptive) { + const mindsetMatch = block.text.match(/\[\[SET_MINDSET:\s*(\w+)\]\]/i); + if (mindsetMatch) { + const m = mindsetMatch[1].toLowerCase(); + if (m === 'convergent' || m === 'divergent' || m === 'algorithmic') { + this.currentMindset = m as ReasoningMindset; + } + } + } const modeRequest = parseModeRequest(block.text); if (modeRequest) { this.modeState = this.modeOrchestrator.requestModeChange( @@ -478,11 +565,16 @@ export class EnhancedAgent extends EventEmitter { if (typeof input?.path === 'string') this.filesChanged.add(input.path); } + const success = !result.error && result.success !== false; this.emit('tool_result', { name: toolUse.name, result, - success: !result.error && result.success !== false, + success, }); + if (this.sessionMemory) { + const msg = typeof result === 'object' && result?.message != null ? String(result.message) : undefined; + this.sessionMemory.recordAttempt(toolUse.name, success, msg); + } toolResults.push({ type: 'tool_result' as const, @@ -566,7 +658,7 @@ export class EnhancedAgent extends EventEmitter { } const params: any = { - model: this.config.model, + model: this.getModelForTier(), max_tokens: 8192, messages: this.messages, system: this.getSystemPrompt(), @@ -740,7 +832,7 @@ export class EnhancedAgent extends EventEmitter { const openAiMessages = this.buildOpenAIMessages(); const baseBody: any = { - model: this.config.model, + model: this.getModelForTier(), messages: openAiMessages, max_tokens: 16000, }; @@ -928,6 +1020,16 @@ export class EnhancedAgent extends EventEmitter { private getSystemPrompt(): string { + // AX-lite strategic tier: plan only, no tools + if (this.currentTier === 'strategic') { + return `You are XibeCode in STRATEGIC PLANNING mode. Given the user's task below, output a concise high-level plan only: +- Main steps in order (numbered or bullet list) +- Key files or areas of the codebase if you can infer them +- Dependencies between steps if any + +Do not use any tools. Do not write code. Output only the plan text.`; + } + const platform = process.platform; const platformNote = platform === 'win32' ? 'You are running on Windows. Use PowerShell commands and Windows path conventions.' @@ -1327,6 +1429,9 @@ When you complete the task, provide a comprehensive summary including: - Potential improvements or follow-up tasks - Test results and validation performed +${this.sessionMemory ? this.sessionMemory.getSummary() : ''} +${this.mindsetAdaptive ? `\n## Current reasoning mindset: ${this.currentMindset.toUpperCase()}\n${this.currentMindset === 'convergent' ? 'Focus on one solution; narrow options and commit. Use [[SET_MINDSET: divergent]] to explore alternatives, or [[SET_MINDSET: algorithmic]] for step-by-step.' : this.currentMindset === 'divergent' ? 'Explore alternatives; brainstorm. Use [[SET_MINDSET: convergent]] to narrow, or [[SET_MINDSET: algorithmic]] for step-by-step.' : 'Reason step-by-step; formal. Use [[SET_MINDSET: convergent]] to commit, or [[SET_MINDSET: divergent]] to explore.'}\n` : ''} +${this.contextHintFiles.length > 0 ? `\n## Suggested relevant files for this task\nPrioritize these when using get_context or read_file:\n${this.contextHintFiles.slice(0, 50).map(f => `- ${f}`).join('\n')}\n` : ''} ${MODE_CONFIG[this.modeState.current].promptSuffix}`; } diff --git a/src/core/context-pruner.ts b/src/core/context-pruner.ts new file mode 100644 index 0000000..b4b06c5 --- /dev/null +++ b/src/core/context-pruner.ts @@ -0,0 +1,121 @@ +/** + * Lightweight context pruning: score files by relevance to the task + * so we can cap what gets suggested to the agent and reduce tokens. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { glob } from 'fast-glob'; + +const DEFAULT_MAX_FILES = 40; +const DEFAULT_EXTENSIONS = ['*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs', '*.py', '*.go', '*.rs', '*.java', '*.md', '*.json']; +const IGNORE_DIRS = ['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '__pycache__', '.venv', 'vendor']; + +export interface PruneOptions { + maxFiles?: number; + extensions?: string[]; + /** If true, include a simple content snippet (first 500 chars) in scoring. */ + useContent?: boolean; + /** If true, augment with PKG-style code graph (AST/import-based relevance). Requires CodeGraph. */ + usePkgStyleContext?: boolean; +} + +/** + * Extract meaningful words from the task (ignore stopwords, short tokens). + */ +function taskWords(task: string): Set<string> { + const stop = new Set(['the', 'and', 'for', 'with', 'this', 'that', 'from', 'have', 'has', 'can', 'will', 'are', 'was', 'were', 'been', 'being', 'into', 'through', 'during', 'before', 'after', 'when', 'where', 'which', 'what', 'your', 'need', 'add', 'fix', 'make', 'use', 'file', 'files', 'code']); + const normalized = task.toLowerCase().replace(/[^\w\s]/g, ' '); + const words = new Set<string>(); + for (const w of normalized.split(/\s+/)) { + if (w.length >= 2 && !stop.has(w)) words.add(w); + } + return words; +} + +/** + * Score a file path (and optionally a content snippet) against task words. + * Returns a number >= 0; higher = more relevant. + */ +function scorePathAndContent(filePath: string, content: string | null, words: Set<string>): number { + const pathLower = filePath.toLowerCase().replace(/\\/g, '/'); + const pathParts = pathLower.split('/'); + let score = 0; + for (const w of words) { + if (pathLower.includes(w)) { + // Prefer matches in filename over deep path + const fileName = pathParts[pathParts.length - 1] ?? ''; + if (fileName.includes(w)) score += 3; + else score += 1; + } + } + if (content) { + const contentLower = content.toLowerCase(); + for (const w of words) { + if (contentLower.includes(w)) score += 1; + } + } + return score; +} + +/** + * List candidate files in workingDir (by extensions), optionally read a small + * content preview, score by relevance to the task, and return top maxFiles paths. + * When usePkgStyleContext is true, augments with PKG-style code graph (AST) results. + */ +export async function pruneContext( + workingDir: string, + task: string, + options: PruneOptions = {} +): Promise<string[]> { + const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; + const extensions = options.extensions ?? DEFAULT_EXTENSIONS; + const useContent = options.useContent ?? false; + const usePkgStyleContext = options.usePkgStyleContext ?? false; + + const words = taskWords(task); + const patterns = extensions.map(ext => `**/${ext}`); + const ignore = IGNORE_DIRS.map(d => `**/${d}/**`); + const files = words.size > 0 + ? await glob(patterns, { cwd: workingDir, absolute: false, ignore, onlyFiles: true }) + : []; + + const scored: { path: string; score: number }[] = []; + + for (const rel of files) { + let content: string | null = null; + if (useContent) { + try { + const full = path.join(workingDir, rel); + const buf = await fs.readFile(full, 'utf-8').catch(() => ''); + content = buf.slice(0, 500); + } catch { + // skip content + } + } + const score = scorePathAndContent(rel, content, words); + scored.push({ path: rel, score }); + } + + scored.sort((a, b) => b.score - a.score); + + const withScore = scored.filter(s => s.score > 0); + let top = withScore.length > 0 + ? withScore.slice(0, maxFiles).map(s => s.path) + : scored.slice(0, maxFiles).map(s => s.path); + + if (usePkgStyleContext && task.trim().length > 0) { + try { + const { CodeGraph } = await import('./code-graph.js'); + const codeGraph = new CodeGraph(workingDir); + const graphResults = await codeGraph.search(task.trim().slice(0, 100)); + const graphPaths = [...new Set(graphResults.map(r => r.filePath).filter(Boolean))]; + const combined = [...new Set([...top, ...graphPaths])].slice(0, maxFiles); + top = combined; + } catch { + // Non-fatal: fall back to keyword-only + } + } + + return top; +} diff --git a/src/core/modes.ts b/src/core/modes.ts index a41ca50..5fbf82e 100644 --- a/src/core/modes.ts +++ b/src/core/modes.ts @@ -955,6 +955,7 @@ const TOOL_CATEGORIES: Record<string, ToolCategory> = { // Shell and test tools 'run_command': 'shell_command', + 'synthesize_tool': 'shell_command', 'run_tests': 'tests', 'get_test_status': 'tests', diff --git a/src/core/session-memory.ts b/src/core/session-memory.ts new file mode 100644 index 0000000..f4bfe79 --- /dev/null +++ b/src/core/session-memory.ts @@ -0,0 +1,125 @@ +/** + * Persistent session memory for a single run (and optionally across runs). + * Records tool attempts, failures, and "what we learned" to avoid repeating mistakes. + * Used by the agent to inject a compact summary into the system prompt. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; + +export interface AttemptRecord { + tool: string; + success: boolean; + message?: string; + ts: number; +} + +export interface SessionMemoryData { + sessionId: string; + startedAt: number; + attempts: AttemptRecord[]; + learnings: string[]; +} + +const MAX_ATTEMPTS_IN_SUMMARY = 15; +const MAX_LEARNINGS_IN_SUMMARY = 5; +const SUMMARY_FAILURE_CAP = 8; + +/** + * Session memory: persists for the run and optionally to .xibecode/sessions/ + * so future runs can load "what we learned" and recent failures. + */ +export class SessionMemory { + private sessionId: string; + private startedAt: number; + private attempts: AttemptRecord[] = []; + private learnings: string[] = []; + private persistDir: string; + + constructor( + private workingDir: string, + sessionId?: string + ) { + this.sessionId = sessionId ?? `run_${Date.now()}`; + this.startedAt = Date.now(); + this.persistDir = path.join(workingDir, '.xibecode', 'sessions'); + } + + getSessionId(): string { + return this.sessionId; + } + + /** Record a tool attempt (success or failure). */ + recordAttempt(tool: string, success: boolean, message?: string): void { + this.attempts.push({ + tool, + success, + message: message && message.slice(0, 500), + ts: Date.now(), + }); + } + + /** Record a short "what we learned" note (e.g. after a failure or retry). */ + recordLearning(summary: string): void { + const s = summary.trim().slice(0, 500); + if (s) this.learnings.push(s); + } + + /** + * Compact summary for the system prompt: recent failures + learnings + * so the agent can avoid repeating the same mistakes. + */ + getSummary(): string { + const failures = this.attempts + .filter(a => !a.success) + .slice(-SUMMARY_FAILURE_CAP) + .map(a => `- ${a.tool}${a.message ? `: ${a.message}` : ''}`); + + const recentLearnings = this.learnings.slice(-MAX_LEARNINGS_IN_SUMMARY); + + const parts: string[] = []; + if (failures.length > 0) { + parts.push('Recent failures to avoid repeating:\n' + failures.join('\n')); + } + if (recentLearnings.length > 0) { + parts.push('Session learnings:\n' + recentLearnings.map(l => `- ${l}`).join('\n')); + } + if (parts.length === 0) return ''; + return '\n## Session memory (this run)\n\n' + parts.join('\n\n') + '\n'; + } + + /** Persist session to .xibecode/sessions/<sessionId>.json for optional cross-run loading. */ + async persist(): Promise<void> { + try { + await fs.mkdir(this.persistDir, { recursive: true }); + const data: SessionMemoryData = { + sessionId: this.sessionId, + startedAt: this.startedAt, + attempts: this.attempts.slice(-MAX_ATTEMPTS_IN_SUMMARY * 2), + learnings: this.learnings, + }; + const file = path.join(this.persistDir, `${this.sessionId}.json`); + await fs.writeFile(file, JSON.stringify(data, null, 2), 'utf-8'); + } catch { + // Non-fatal + } + } + + /** Load a previous session's learnings (e.g. last run) to prime this run. */ + async loadPreviousLearnings(limit: number = MAX_LEARNINGS_IN_SUMMARY): Promise<void> { + try { + const entries = await fs.readdir(this.persistDir).catch(() => []); + const jsonFiles = entries.filter(f => f.endsWith('.json')).sort().reverse(); + for (const f of jsonFiles.slice(0, 3)) { + const content = await fs.readFile(path.join(this.persistDir, f), 'utf-8'); + const data = JSON.parse(content) as SessionMemoryData; + if (data.learnings?.length) { + this.learnings.push(...data.learnings.slice(-limit)); + break; + } + } + } catch { + // Non-fatal + } + } +} diff --git a/src/core/tools.ts b/src/core/tools.ts index a89629a..55739d2 100644 --- a/src/core/tools.ts +++ b/src/core/tools.ts @@ -8,7 +8,7 @@ import { AgentMode, MODE_CONFIG, isToolAllowed, isValidMode } from './modes.js'; import { FileEditor } from './editor.js'; import { GitUtils } from '../utils/git.js'; import { TestRunnerDetector } from '../utils/testRunner.js'; -import { SafetyChecker } from '../utils/safety.js'; +import { SafetyChecker, sanitizePath, sanitizeUrl } from '../utils/safety.js'; import { PluginManager } from './plugins.js'; import { MCPClientManager } from './mcp-client.js'; import { NeuralMemory } from './memory.js'; @@ -115,6 +115,8 @@ export class CodingToolExecutor implements ToolExecutor { private platform: string; private dryRun: boolean; private testCommandOverride?: string; + /** Session-scoped tools synthesized by the agent (meta-agent). Execution is sandboxed via run_command. */ + private dynamicTools = new Map<string, { description: string; script: string }>(); /** * Creates a new CodingToolExecutor instance @@ -343,6 +345,10 @@ export class CodingToolExecutor implements ToolExecutor { } } + try { + if (this.dynamicTools.has(toolName)) { + return this.runDynamicTool(toolName, p); + } switch (toolName) { case 'read_file': { if (!p.path || typeof p.path !== 'string') { @@ -788,8 +794,29 @@ export class CodingToolExecutor implements ToolExecutor { }; } + case 'synthesize_tool': { + const name = typeof p.name === 'string' ? p.name.trim() : ''; + const description = typeof p.description === 'string' ? p.description.trim() : ''; + const script = typeof p.script === 'string' ? p.script.trim() : ''; + if (!name || !script) { + return { error: true, success: false, message: 'Missing required parameters: name (string), script (string). description is optional.' }; + } + if (!/^[a-z][a-z0-9_]*$/.test(name)) { + return { error: true, success: false, message: 'Tool name must be lowercase letters, numbers, underscores only (e.g. my_helper).' }; + } + const reserved = new Set(['read_file', 'write_file', 'run_command', 'synthesize_tool', 'get_context']); + if (reserved.has(name)) { + return { error: true, success: false, message: `Cannot override built-in tool: ${name}` }; + } + this.dynamicTools.set(name, { description: description || name, script }); + return { success: true, message: `Tool "${name}" registered. You can call it with the same name. Execution is sandboxed.` }; + } + default: - return { error: true, success: false, message: `Unknown tool: ${toolName}. Available tools: read_file, read_multiple_files, write_file, edit_file, edit_lines, insert_at_line, verified_edit, list_directory, search_files, run_command, create_directory, delete_file, move_file, get_context, revert_file, run_tests, get_test_status, get_git_status, get_git_diff_summary, get_git_changed_files, create_git_checkpoint, revert_to_git_checkpoint, git_show_diff, get_mcp_status, grep_code, web_search, fetch_url, remember_lesson, take_screenshot, get_console_logs, run_visual_test, check_accessibility, measure_performance, test_responsive, capture_network, run_playwright_test, search_skills_sh, install_skill_from_skills_sh, preview_app, delegate_subtask` }; + return { error: true, success: false, message: `Unknown tool: ${toolName}. Available tools: read_file, read_multiple_files, write_file, edit_file, edit_lines, insert_at_line, verified_edit, list_directory, search_files, run_command, create_directory, delete_file, move_file, get_context, revert_file, run_tests, get_test_status, get_git_status, get_git_diff_summary, get_git_changed_files, create_git_checkpoint, revert_to_git_checkpoint, git_show_diff, get_mcp_status, grep_code, web_search, fetch_url, remember_lesson, synthesize_tool, take_screenshot, get_console_logs, run_visual_test, check_accessibility, measure_performance, test_responsive, capture_network, run_playwright_test, search_skills_sh, install_skill_from_skills_sh, preview_app, delegate_subtask` }; + } + } catch (err: any) { + return { error: true, success: false, message: err?.message ?? String(err) }; } } @@ -1434,6 +1461,19 @@ export class CodingToolExecutor implements ToolExecutor { required: ['trigger', 'action', 'outcome'] } }, + { + name: 'synthesize_tool', + description: 'Register a new session-scoped tool (meta-agent). Use when you need a reusable script for repeated operations or after repeated failures. The script runs in the same sandbox as run_command. Name must be lowercase with underscores (e.g. my_helper).', + input_schema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Tool name (lowercase, letters/numbers/underscores only)' }, + description: { type: 'string', description: 'Short description of what the tool does' }, + script: { type: 'string', description: 'Shell command or script to run when the tool is invoked (e.g. "grep -r pattern src/")' } + }, + required: ['name', 'script'] + } + }, { name: 'take_screenshot', description: 'Take a screenshot of a web page. Useful for verifying UI appearance or capturing the state of a web app.', @@ -1677,7 +1717,12 @@ export class CodingToolExecutor implements ToolExecutor { // Merge plugin tools const pluginTools = this.pluginManager.getPluginTools(); - return [...coreTools, ...mcpTools, ...pluginTools]; + const dynamicToolDefs: Tool[] = Array.from(this.dynamicTools.entries()).map(([name, def]) => ({ + name, + description: `[Session tool] ${def.description}`, + input_schema: { type: 'object' as const, properties: {} }, + })); + return [...coreTools, ...dynamicToolDefs, ...mcpTools, ...pluginTools]; } /** @@ -1689,7 +1734,9 @@ export class CodingToolExecutor implements ToolExecutor { * @internal */ private resolvePath(filePath: string): string { - return path.resolve(this.workingDir, filePath); + const result = sanitizePath(this.workingDir, filePath); + if (!result.ok) throw new Error(result.message); + return result.path; } /** @@ -2021,6 +2068,12 @@ export class CodingToolExecutor implements ToolExecutor { * @risk-level High * @since 0.1.0 */ + private async runDynamicTool(toolName: string, _input: any): Promise<any> { + const def = this.dynamicTools.get(toolName); + if (!def) return { error: true, success: false, message: `Dynamic tool "${toolName}" not found` }; + return this.runCommand(def.script, this.workingDir, undefined, 60); + } + private async runCommand(command: string, cwd?: string, input?: string, timeout?: number): Promise<any> { const workDir = cwd ? this.resolvePath(cwd) : this.workingDir; const timeoutMs = (timeout || 120) * 1000; @@ -2790,8 +2843,12 @@ export class CodingToolExecutor implements ToolExecutor { // ── fetch_url: read any URL as text ──────────────────────── private async fetchUrl(url: string, maxLength: number = 20000): Promise<any> { + const urlResult = sanitizeUrl(url.trim()); + if (!urlResult.ok) { + return { error: true, success: false, message: urlResult.message }; + } try { - const response = await fetch(url, { + const response = await fetch(urlResult.url, { headers: { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,text/plain,application/json', @@ -2837,7 +2894,7 @@ export class CodingToolExecutor implements ToolExecutor { return { success: true, - url, + url: urlResult.url, contentType: contentType.split(';')[0], length: text.length, truncated, diff --git a/src/index.ts b/src/index.ts index 371706e..c23de86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,9 @@ program .option('--provider <provider>', 'Model API format: anthropic or openai') .option('-d, --max-iterations <number>', 'Maximum iterations (0 = unlimited, default 150)', '150') .option('-v, --verbose', 'Show detailed logs', false) + .option('--cost-mode <mode>', 'Cost mode: normal or economy (cheaper model, lower iteration/token caps)', 'normal') + .option('--plan-first', 'Force a strategic plan (one-shot, no tools) before execution (AX-lite)', false) + .option('--mindset-adaptive', 'Enable CoM-style reasoning mindsets (convergent/divergent/algorithmic)', false) .option('--dry-run', 'Preview changes without making them', false) .option('--changed-only', 'Focus only on git-changed files', false) .option('--non-interactive', 'Run in non-interactive mode (for background tasks)', false) @@ -53,6 +56,9 @@ program .option('--provider <provider>', 'Model API format: anthropic or openai') .option('-d, --max-iterations <number>', 'Maximum iterations (0 = unlimited, default 150)', '150') .option('-v, --verbose', 'Show detailed logs', false) + .option('--cost-mode <mode>', 'Cost mode: normal or economy (cheaper model, lower caps)', 'normal') + .option('--plan-first', 'Force a strategic plan before execution (AX-lite)', false) + .option('--mindset-adaptive', 'Enable CoM-style reasoning mindsets', false) .option('--branch <name>', 'Override branch name (default: auto-generated xibecode/<slug>-<timestamp>)') .option('--title <title>', 'Override PR title (default: derived from prompt)') .option('--draft', 'Open PR as draft', false) @@ -67,6 +73,7 @@ program .option('-b, --base-url <url>', 'Custom API base URL') .option('-k, --api-key <key>', 'API key (overrides config)') .option('--provider <provider>', 'Model API format: anthropic or openai') + .option('--cost-mode <mode>', 'Cost mode: normal or economy', 'normal') .option('--theme <theme>', 'UI theme to use') .option('--session <id>', 'Resume a specific chat session by id') .option('--no-webui', 'Disable WebUI server (TUI only)') @@ -118,6 +125,8 @@ program .option('--set-url <url>', 'Set custom base URL') .option('--set-model <model>', 'Set default model') .option('--set-provider <provider>', 'Set default provider/API format (anthropic, openai, deepseek, zai, kimi, grok, openrouter, google, auto)') + .option('--set-cost-mode <mode>', 'Set cost mode: normal or economy (use cheaper model and lower caps)') + .option('--set-economy-model <model>', 'Set model to use when cost mode is economy') .option('--show', 'Show current configuration') .option('--reset', 'Reset all configuration') .option('--list-mcp-servers', 'List configured MCP servers') diff --git a/src/utils/config.ts b/src/utils/config.ts index 1682193..4ac58f2 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -123,6 +123,20 @@ export interface XibeCodeConfig { defaultEditor?: string; statusBarEnabled?: boolean; headerMinimal?: boolean; + // Cost-saving / economy mode + costMode?: 'normal' | 'economy'; + economyModel?: string; + economyMaxTokens?: number; + economyMaxIterations?: number; + tokenCapPerSession?: number; + /** Max files to suggest from context pruning (0 = disable). Default 40. */ + maxContextFiles?: number; + /** Model for strategic/planning tier (multi-model routing). */ + planningModel?: string; + /** Model for tactical/operational tier (multi-model routing). */ + executionModel?: string; + /** When true, augment context pruning with PKG-style code graph (AST). */ + usePkgStyleContext?: boolean; } export class ConfigManager { @@ -153,6 +167,11 @@ export class ConfigManager { compactThreshold: 50000, statusBarEnabled: true, headerMinimal: false, + costMode: 'normal', + economyMaxTokens: 4096, + economyMaxIterations: 50, + maxContextFiles: 40, + usePkgStyleContext: false, }, }); } @@ -264,12 +283,55 @@ export class ConfigManager { } /** - * Get model from config or environment + * Get model from config or environment. + * In economy mode, returns economyModel if set, otherwise default. */ - getModel(): string { + getModel(economy?: boolean): string { + const useEconomy = economy ?? (this.getCostMode() === 'economy'); + if (useEconomy && this.get('economyModel')) { + return this.get('economyModel')!; + } return this.get('model') || process.env.XIBECODE_MODEL || 'claude-sonnet-4-5-20250929'; } + getCostMode(): 'normal' | 'economy' { + return this.get('costMode') || 'normal'; + } + + getEconomyModel(): string | undefined { + return this.get('economyModel'); + } + + getEconomyMaxTokens(): number { + return this.get('economyMaxTokens') ?? 4096; + } + + getEconomyMaxIterations(): number { + return this.get('economyMaxIterations') ?? 50; + } + + getTokenCapPerSession(): number | undefined { + return this.get('tokenCapPerSession'); + } + + /** Max files to suggest from context pruning; 0 means disabled. */ + getMaxContextFiles(): number { + const v = this.get('maxContextFiles'); + return v !== undefined && v !== null ? Number(v) : 40; + } + + getPlanningModel(): string | undefined { + return this.get('planningModel'); + } + + getExecutionModel(): string | undefined { + return this.get('executionModel'); + } + + getUsePkgStyleContext(): boolean { + return this.get('usePkgStyleContext') ?? false; + } + /** * Get preferred theme name */ @@ -391,6 +453,8 @@ export class ConfigManager { 'Theme': config.theme || 'default', 'Show Details': (config.showDetails ?? config.defaultVerbose ?? false).toString(), 'Show Thinking': (config.showThinking ?? true).toString(), + 'Cost Mode': config.costMode || 'normal', + 'Economy Model': config.economyModel || 'Not set', 'Config Path': this.getConfigPath(), }; } diff --git a/src/utils/safety.ts b/src/utils/safety.ts index 162bc58..34ae095 100644 --- a/src/utils/safety.ts +++ b/src/utils/safety.ts @@ -2,8 +2,46 @@ * Safety utilities for classifying and managing risky operations */ +import * as path from 'path'; + export type RiskLevel = 'low' | 'medium' | 'high'; +/** + * Resolve a file path against a working directory and ensure it stays inside it (no path traversal). + * Use for all user-provided paths before file operations. + */ +export function sanitizePath(workingDir: string, filePath: string): { ok: true; path: string } | { ok: false; message: string } { + const normalized = path.normalize(filePath).replace(/^(\.\.(\/|\\))+/, ''); + const resolved = path.resolve(workingDir, normalized); + const relative = path.relative(workingDir, resolved); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return { ok: false, message: 'Path escapes working directory and is not allowed' }; + } + return { ok: true, path: resolved }; +} + +/** + * Validate URL for fetch_url to reduce SSRF risk: only http/https, no localhost or private IPs by default. + */ +export function sanitizeUrl(url: string, allowLocalhost = false): { ok: true; url: string } | { ok: false; message: string } { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, message: 'Invalid URL' }; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, message: 'Only http and https URLs are allowed' }; + } + if (!allowLocalhost) { + const host = (parsed.hostname || '').toLowerCase(); + if (host === 'localhost' || host === '127.0.0.1' || host.startsWith('192.168.') || host.startsWith('10.') || host.endsWith('.local')) { + return { ok: false, message: 'Local or private URLs are not allowed' }; + } + } + return { ok: true, url: parsed.toString() }; +} + export interface RiskAssessment { level: RiskLevel; reasons: string[];