From 5818650e53c236959faffedd2e123155aef5de7c Mon Sep 17 00:00:00 2001 From: kyuna0312 Date: Fri, 17 Apr 2026 14:26:27 +0900 Subject: [PATCH 1/5] docs: add token/cost hints design spec --- .../2026-04-17-token-cost-hints-design.md | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-17-token-cost-hints-design.md diff --git a/docs/superpowers/specs/2026-04-17-token-cost-hints-design.md b/docs/superpowers/specs/2026-04-17-token-cost-hints-design.md new file mode 100644 index 0000000..008bda9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-token-cost-hints-design.md @@ -0,0 +1,228 @@ +# Token / Cost Hints Design + +**Date:** 2026-04-17 +**Status:** Approved + +## Goal + +Show live token-usage and estimated cost hints in the chat interface — both as a persistent chrome line and via a `/tokens` slash command — using a local character heuristic (no extra API calls, no telemetry). + +--- + +## Approach + +Character-based token estimation: `tokens ≈ chars / 4`. Industry-standard approximation with ~10–15% error margin. All displayed numbers are prefixed with `~` to signal "estimated". Zero added dependencies, zero latency cost, works offline and for all providers. + +--- + +## Architecture + +### New files + +| File | Responsibility | +|------|---------------| +| `packages/cli/src/utils/token-estimate.ts` | Pure token estimation functions | +| `packages/cli/src/utils/token-pricing.ts` | Bundled pricing + context-window tables, user-override resolution | + +### Modified files + +| File | Change | +|------|--------| +| `packages/cli/src/config.ts` | Add `tokenHints` to `RawConfigSchema` and `InariConfig` | +| `packages/cli/src/ui/chat-chrome.ts` | Append token hint line to REPL chrome | +| `packages/cli/src/ui/chat-tui.tsx` | Add persistent footer with token hint | +| `packages/cli/src/ui/chat-slash.ts` | Add `/tokens` command handler | +| `packages/cli/src/ui/chat-repl.ts` | Pass token state to chrome + slash | + +--- + +## Token Estimation (`token-estimate.ts`) + +```typescript +export type TurnTokens = { + turn: number; // 1-based index + input: number; // user_text + tool_outputs tokens + output: number; // assistant tokens +}; + +export type HistoryTokens = { + input: number; + output: number; + total: number; + byTurn: TurnTokens[]; +}; + +export function estimateTokens(text: string): number; +// → Math.ceil(text.length / 4) + +export function estimateHistoryTokens(history: AgentHistoryItem[]): HistoryTokens; +// Segments by user turn. user_text + tool_outputs = input. assistant = output. + +export function formatTokenCount(n: number): string; +// → "320", "1.2k", "42k", "1.2M" +``` + +--- + +## Pricing + Context Window (`token-pricing.ts`) + +### Bundled defaults + +`DEFAULT_PRICING` — model substring → `{ inputPerMToken: number; outputPerMToken: number }`: + +| Pattern | Input $/M | Output $/M | +|---------|-----------|------------| +| `claude-opus-4` | 15.00 | 75.00 | +| `claude-sonnet-4` | 3.00 | 15.00 | +| `claude-haiku-4` | 0.80 | 4.00 | +| `gpt-4o` | 2.50 | 10.00 | +| `gpt-4o-mini` | 0.15 | 0.60 | +| `gemini-2.0-flash` | 0.10 | 0.40 | +| `*` (fallback) | — | — (n/a) | + +`DEFAULT_CONTEXT_WINDOW` — model substring → tokens: + +| Pattern | Tokens | +|---------|--------| +| `claude` | 200000 | +| `gpt-4o` | 128000 | +| `gemini` | 1000000 | +| `*` (fallback) | 128000 | + +### Resolution + +`resolvePricing(model: string, userOverrides: Record): PricingEntry | null` +— longest substring match wins; user overrides checked first. + +`resolveContextWindow(model: string, userOverrides: Record): number` +— same matching strategy; user overrides checked first. + +`estimateCost(input: number, output: number, pricing: PricingEntry | null): number | null` +— returns null when no pricing entry (cost shown as `n/a`). + +--- + +## Config (`tokenHints` key) + +### `inaricode.yaml` + +```yaml +tokenHints: + enabled: true # default: true; false hides all hints + pricing: # override $/M tokens per model substring + my-custom-model: + inputPerMToken: 1.00 + outputPerMToken: 5.00 + contextWindow: # override context window in tokens + my-custom-model: 32000 +``` + +### `InariConfig` + +```typescript +tokenHints: { + enabled: boolean; + pricing: Record; + contextWindow: Record; +}; +``` + +Defaults: `enabled: true`, empty override maps (bundled defaults apply). + +--- + +## Chrome Display + +### Format + +``` +~ 4.2k / 200k tokens · $0.013 ▓░░░░░░░░░ 2% +``` + +- `~` prefix on all numbers signals estimation +- Bar: 10 `▓`/`░` characters; filled = `Math.round(pct / 10)` blocks +- Color thresholds (ANSI, skipped when `INARI_PLAIN=1`): + - < 80% → default color + - ≥ 80% → yellow + - ≥ 95% → red +- `INARI_PLAIN=1`: bar replaced with `[2%]` +- Cost omitted (replaced with nothing) when model has no pricing entry +- `tokenHints.enabled: false` → line not rendered at all + +### Placement + +- **REPL**: printed on its own line after each assistant response +- **TUI**: persistent footer line below the input box, updated after each turn via Ink state + +--- + +## `/tokens` Slash Command + +Output format (written to transcript / stdout): + +``` +Session tokens (estimated) + User + tool input : ~12.4k $0.037 + Assistant output : ~ 3.1k $0.047 + ───────────────────────────────── + Total : ~15.5k $0.084 + +Context window: ~15.5k / 200k (8%) + +Turn Input Output Cost + 1 ~320 ~180 $0.004 + 2 ~1.2k ~640 $0.012 + 3 ~480 ~210 $0.006 +``` + +- Capped at last 10 turns +- `Cost` column shows `n/a` when model has no pricing entry +- Works in both REPL and TUI (writes to transcript in TUI) +- Added to `/help` output + +--- + +## Data Flow + +``` +AgentHistoryItem[] (after each turn) + → estimateHistoryTokens() # token-estimate.ts + → resolvePricing(model, overrides) # token-pricing.ts + → estimateCost(input, output) + → TokenHintState { tokens, cost, pct } + → chat-chrome.ts / chat-tui.tsx # chrome bar + → /tokens handler in chat-slash.ts # on-demand detail +``` + +`TokenHintState` is computed once per turn and passed as props/args — not stored in session files. + +--- + +## Error Handling + +- `estimateTokens` never throws — empty string returns 0 +- `resolvePricing` returns `null` on no match — cost shown as `n/a`, not an error +- `resolveContextWindow` always returns a number (fallback: 128000) — bar always renders +- Hint computation failure must not crash the agent loop — wrap in try/catch in chrome render, fall back to hiding the line silently + +--- + +## Testing + +| File | What to test | +|------|-------------| +| `test/token-estimate.test.ts` | `estimateTokens`, `estimateHistoryTokens` (segmentation, input/output split), `formatTokenCount` edge cases | +| `test/token-pricing.test.ts` | Model pattern matching (longest match, user override priority), `estimateCost` (null pricing → null), context window resolution | +| `test/config-keys.test.ts` | `tokenHints` config loads; defaults apply when omitted | +| `test/slash-tokens.test.ts` | `/tokens` output format (role breakdown, per-turn table, n/a cost) | + +Chrome visual output not unit-tested — validated manually with `yarn cli chat`. + +--- + +## Non-Goals + +- Exact token counts (tiktoken, API call) — approximation is intentional +- Persistent cost tracking across sessions — hints are per-session, ephemeral +- Telemetry or usage reporting — all local +- Token budget enforcement / hard limits — read-only hints only From 46b0de5aff9fcdb05ac37423bc67cafe511cb9f6 Mon Sep 17 00:00:00 2001 From: kyuna0312 Date: Fri, 17 Apr 2026 14:27:18 +0900 Subject: [PATCH 2/5] docs: clarify tokenHints.enabled scope for /tokens command --- docs/superpowers/specs/2026-04-17-token-cost-hints-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-04-17-token-cost-hints-design.md b/docs/superpowers/specs/2026-04-17-token-cost-hints-design.md index 008bda9..6d14543 100644 --- a/docs/superpowers/specs/2026-04-17-token-cost-hints-design.md +++ b/docs/superpowers/specs/2026-04-17-token-cost-hints-design.md @@ -147,7 +147,7 @@ Defaults: `enabled: true`, empty override maps (bundled defaults apply). - ≥ 95% → red - `INARI_PLAIN=1`: bar replaced with `[2%]` - Cost omitted (replaced with nothing) when model has no pricing entry -- `tokenHints.enabled: false` → line not rendered at all +- `tokenHints.enabled: false` → chrome line not rendered; `/tokens` command still works ### Placement From fb92f7b228c66c2b32b7344b8a46b0c64055e428 Mon Sep 17 00:00:00 2001 From: kyuna0312 Date: Fri, 17 Apr 2026 14:35:25 +0900 Subject: [PATCH 3/5] docs: add token/cost hints implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-04-17-token-cost-hints.md | 1094 +++++++++++++++++ 1 file changed, 1094 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-17-token-cost-hints.md diff --git a/docs/superpowers/plans/2026-04-17-token-cost-hints.md b/docs/superpowers/plans/2026-04-17-token-cost-hints.md new file mode 100644 index 0000000..86becdd --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-token-cost-hints.md @@ -0,0 +1,1094 @@ +# Token / Cost Hints Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show live estimated token-usage and cost hints in both the REPL chrome line and a `/tokens` slash command, using a local `chars / 4` heuristic with no extra dependencies. + +**Architecture:** Two new pure utility modules (`token-estimate.ts`, `token-pricing.ts`) feed a `TokenHintState` object into updated chrome rendering in `chat-chrome.ts`; the REPL prints one hint line after each turn; the TUI shows a persistent footer; `/tokens` slash command prints a detailed breakdown on demand. + +**Tech Stack:** TypeScript (strict ESM), Vitest, existing Ink + ANSI palette from `chat-chrome.ts` + +--- + +## File Structure + +| Path | Action | Responsibility | +|------|--------|---------------| +| `packages/cli/src/utils/token-estimate.ts` | **Create** | `estimateTokens`, `estimateHistoryTokens`, `formatTokenCount` | +| `packages/cli/src/utils/token-pricing.ts` | **Create** | Bundled pricing/context-window tables, `resolvePricing`, `resolveContextWindow`, `estimateCost` | +| `packages/cli/test/token-estimate.test.ts` | **Create** | Unit tests for estimation + formatting | +| `packages/cli/test/token-pricing.test.ts` | **Create** | Unit tests for pattern matching + cost calculation | +| `packages/cli/src/config.ts` | **Modify** | Add `tokenHints` to `RawConfigSchema` and `InariConfig` | +| `packages/cli/test/config-keys.test.ts` | **Modify** | Add `tokenHints` config tests | +| `packages/cli/src/ui/chat-chrome.ts` | **Modify** | Add `TokenHintState`, `renderTokenHintLine`, `computeTokenHintLine` | +| `packages/cli/src/ui/chat-repl.ts` | **Modify** | Print hint line after each turn | +| `packages/cli/src/ui/chat-tui.tsx` | **Modify** | Add `tokenHint` state + footer Text | +| `packages/cli/src/ui/chat-slash.ts` | **Modify** | Add `model` + `tokenHintsConfig` to `SlashCtx`; handle `/tokens` | +| `packages/cli/test/slash-tokens.test.ts` | **Create** | Unit tests for `/tokens` output | +| `packages/cli/src/i18n/strings.ts` | **Modify** | Add `/tokens` entry to help text | + +--- + +### Task 1: `token-estimate.ts` — estimation + formatting + +**Files:** +- Create: `packages/cli/src/utils/token-estimate.ts` +- Create: `packages/cli/test/token-estimate.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +// packages/cli/test/token-estimate.test.ts +import { describe, expect, it } from "vitest"; +import { + estimateTokens, + estimateHistoryTokens, + formatTokenCount, +} from "../src/utils/token-estimate.js"; +import type { AgentHistoryItem } from "../src/llm/types.js"; + +function u(t: string): AgentHistoryItem { return { kind: "user_text", text: t }; } +function a(text: string): AgentHistoryItem { + return { kind: "assistant", blocks: [{ type: "text", text }] }; +} +function tool(id: string, content: string): AgentHistoryItem { + return { kind: "tool_outputs", outputs: [{ id, content }] }; +} + +describe("estimateTokens", () => { + it("returns 0 for empty string", () => { + expect(estimateTokens("")).toBe(0); + }); + + it("rounds up chars/4", () => { + expect(estimateTokens("abcd")).toBe(1); // 4/4 = 1 + expect(estimateTokens("abcde")).toBe(2); // 5/4 = 1.25 → ceil = 2 + expect(estimateTokens("a".repeat(100))).toBe(25); + }); +}); + +describe("formatTokenCount", () => { + it("returns plain number below 1000", () => { + expect(formatTokenCount(0)).toBe("0"); + expect(formatTokenCount(320)).toBe("320"); + expect(formatTokenCount(999)).toBe("999"); + }); + + it("formats thousands with one decimal", () => { + expect(formatTokenCount(1000)).toBe("1k"); + expect(formatTokenCount(1200)).toBe("1.2k"); + expect(formatTokenCount(1250)).toBe("1.3k"); // rounds + expect(formatTokenCount(42000)).toBe("42k"); + }); + + it("formats millions", () => { + expect(formatTokenCount(1_000_000)).toBe("1M"); + expect(formatTokenCount(1_500_000)).toBe("1.5M"); + }); +}); + +describe("estimateHistoryTokens", () => { + it("returns zeros for empty history", () => { + const result = estimateHistoryTokens([]); + expect(result).toEqual({ input: 0, output: 0, total: 0, byTurn: [] }); + }); + + it("counts user_text as input, assistant as output", () => { + // "aaaa" = 4 chars = 1 token; "bbbbbbbb" = 8 chars = 2 tokens + const h: AgentHistoryItem[] = [u("aaaa"), a("bbbbbbbb")]; + const result = estimateHistoryTokens(h); + expect(result.input).toBe(1); + expect(result.output).toBe(2); + expect(result.total).toBe(3); + }); + + it("counts tool_outputs as input", () => { + const h: AgentHistoryItem[] = [ + u("aaaa"), + { kind: "tool_outputs", outputs: [{ id: "t1", content: "aaaa" }] }, + a("bbbb"), + ]; + const result = estimateHistoryTokens(h); + expect(result.input).toBe(2); // user + tool + expect(result.output).toBe(1); + }); + + it("segments into turns correctly", () => { + const h: AgentHistoryItem[] = [ + u("aaaa"), a("bbbb"), + u("cccc"), a("dddd"), + ]; + const result = estimateHistoryTokens(h); + expect(result.byTurn).toHaveLength(2); + expect(result.byTurn[0]).toEqual({ turn: 1, input: 1, output: 1 }); + expect(result.byTurn[1]).toEqual({ turn: 2, input: 1, output: 1 }); + }); + + it("counts tool_use blocks in assistant as output", () => { + const h: AgentHistoryItem[] = [ + { + kind: "assistant", + blocks: [ + { type: "text", text: "aaaa" }, + { type: "tool_use", id: "t1", name: "read_file", input: { path: "x" } }, + ], + }, + ]; + const result = estimateHistoryTokens(h); + expect(result.output).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run to confirm failure** + +```bash +/home/kyuna/Desktop/inaricode/node_modules/.bin/vitest run packages/cli/test/token-estimate.test.ts +``` + +Expected: `Cannot find module '../src/utils/token-estimate.js'` + +- [ ] **Step 3: Create `token-estimate.ts`** + +```typescript +// packages/cli/src/utils/token-estimate.ts +import type { AgentHistoryItem } from "../llm/types.js"; +import { segmentHistoryByUserTurns } from "../session/compact-history.js"; + +export type TurnTokens = { + turn: number; + input: number; + output: number; +}; + +export type HistoryTokens = { + input: number; + output: number; + total: number; + byTurn: TurnTokens[]; +}; + +export function estimateTokens(text: string): number { + if (text.length === 0) return 0; + return Math.ceil(text.length / 4); +} + +export function formatTokenCount(n: number): string { + if (n < 1_000) return String(n); + if (n < 1_000_000) { + const k = n / 1_000; + const rounded = Math.round(k * 10) / 10; + return rounded >= 100 ? `${Math.round(rounded)}k` : `${rounded}k`.replace(/\.0k$/, "k"); + } + const m = Math.round((n / 1_000_000) * 10) / 10; + return `${m}M`.replace(/\.0M$/, "M"); +} + +export function estimateHistoryTokens(history: AgentHistoryItem[]): HistoryTokens { + let input = 0; + let output = 0; + const byTurn: TurnTokens[] = []; + + const segments = segmentHistoryByUserTurns(history); + for (let i = 0; i < segments.length; i++) { + let turnInput = 0; + let turnOutput = 0; + for (const item of segments[i]!) { + if (item.kind === "user_text") { + turnInput += estimateTokens(item.text); + } else if (item.kind === "assistant") { + for (const b of item.blocks) { + if (b.type === "text") { + turnOutput += estimateTokens(b.text); + } else { + turnOutput += estimateTokens(JSON.stringify(b.input)); + } + } + } else { + for (const o of item.outputs) { + turnInput += estimateTokens(o.content); + } + } + } + input += turnInput; + output += turnOutput; + byTurn.push({ turn: i + 1, input: turnInput, output: turnOutput }); + } + + return { input, output, total: input + output, byTurn }; +} +``` + +- [ ] **Step 4: Run to confirm pass** + +```bash +/home/kyuna/Desktop/inaricode/node_modules/.bin/vitest run packages/cli/test/token-estimate.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/utils/token-estimate.ts packages/cli/test/token-estimate.test.ts +git commit -m "feat(utils): add token estimation and formatting utilities" +``` + +--- + +### Task 2: `token-pricing.ts` — pricing + context window + +**Files:** +- Create: `packages/cli/src/utils/token-pricing.ts` +- Create: `packages/cli/test/token-pricing.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +// packages/cli/test/token-pricing.test.ts +import { describe, expect, it } from "vitest"; +import { + resolvePricing, + resolveContextWindow, + estimateCost, + type PricingEntry, +} from "../src/utils/token-pricing.js"; + +describe("resolvePricing", () => { + it("returns null for unknown model with no overrides", () => { + expect(resolvePricing("my-unknown-model", {})).toBeNull(); + }); + + it("matches claude-sonnet-4 by substring", () => { + const p = resolvePricing("claude-sonnet-4-20250514", {}); + expect(p).not.toBeNull(); + expect(p!.inputPerMToken).toBe(3.0); + expect(p!.outputPerMToken).toBe(15.0); + }); + + it("prefers longer match: gpt-4o-mini over gpt-4o", () => { + const p = resolvePricing("gpt-4o-mini", {}); + expect(p!.inputPerMToken).toBe(0.15); + }); + + it("user override takes priority over defaults", () => { + const overrides: Record = { + "claude-sonnet-4": { inputPerMToken: 1.0, outputPerMToken: 2.0 }, + }; + const p = resolvePricing("claude-sonnet-4-20250514", overrides); + expect(p!.inputPerMToken).toBe(1.0); + }); + + it("user override longest-match wins over shorter user override", () => { + const overrides: Record = { + claude: { inputPerMToken: 9.9, outputPerMToken: 9.9 }, + "claude-sonnet-4": { inputPerMToken: 1.0, outputPerMToken: 2.0 }, + }; + const p = resolvePricing("claude-sonnet-4-20250514", overrides); + expect(p!.inputPerMToken).toBe(1.0); + }); +}); + +describe("resolveContextWindow", () => { + it("returns 128_000 for unknown model", () => { + expect(resolveContextWindow("my-unknown-model", {})).toBe(128_000); + }); + + it("matches claude to 200_000", () => { + expect(resolveContextWindow("claude-sonnet-4", {})).toBe(200_000); + }); + + it("user override wins", () => { + expect(resolveContextWindow("my-model", { "my-model": 32_000 })).toBe(32_000); + }); +}); + +describe("estimateCost", () => { + it("returns null when pricing is null", () => { + expect(estimateCost(1000, 500, null)).toBeNull(); + }); + + it("computes cost correctly", () => { + // 1M input tokens at $3/M = $3.00, 1M output at $15/M = $15.00 + const pricing: PricingEntry = { inputPerMToken: 3.0, outputPerMToken: 15.0 }; + expect(estimateCost(1_000_000, 1_000_000, pricing)).toBeCloseTo(18.0); + }); + + it("returns 0 for zero tokens", () => { + const pricing: PricingEntry = { inputPerMToken: 3.0, outputPerMToken: 15.0 }; + expect(estimateCost(0, 0, pricing)).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run to confirm failure** + +```bash +/home/kyuna/Desktop/inaricode/node_modules/.bin/vitest run packages/cli/test/token-pricing.test.ts +``` + +Expected: `Cannot find module '../src/utils/token-pricing.js'` + +- [ ] **Step 3: Create `token-pricing.ts`** + +```typescript +// packages/cli/src/utils/token-pricing.ts + +export type PricingEntry = { + inputPerMToken: number; + outputPerMToken: number; +}; + +// Ordered list — longer patterns must come before shorter ones that are substrings. +// resolvePricing does longest-match, but consistent ordering prevents surprises. +const DEFAULT_PRICING_TABLE: [string, PricingEntry][] = [ + ["claude-opus-4", { inputPerMToken: 15.0, outputPerMToken: 75.0 }], + ["claude-sonnet-4", { inputPerMToken: 3.0, outputPerMToken: 15.0 }], + ["claude-haiku-4", { inputPerMToken: 0.8, outputPerMToken: 4.0 }], + ["gpt-4o-mini", { inputPerMToken: 0.15, outputPerMToken: 0.6 }], + ["gpt-4o", { inputPerMToken: 2.5, outputPerMToken: 10.0 }], + ["gemini-2.0-flash", { inputPerMToken: 0.1, outputPerMToken: 0.4 }], +]; + +const DEFAULT_CONTEXT_WINDOW_TABLE: [string, number][] = [ + ["claude", 200_000], + ["gpt-4o", 128_000], + ["gemini", 1_000_000], +]; + +const FALLBACK_CONTEXT_WINDOW = 128_000; + +/** Longest substring match: pick entry whose key is the longest substring of `model`. */ +function longestSubstringMatch( + model: string, + table: [string, T][], +): T | null { + const lower = model.toLowerCase(); + let best: T | null = null; + let bestLen = -1; + for (const [pattern, value] of table) { + if (lower.includes(pattern.toLowerCase()) && pattern.length > bestLen) { + best = value; + bestLen = pattern.length; + } + } + return best; +} + +/** + * Resolve pricing for `model`. User overrides checked first (also longest-match). + * Returns null if no entry found — cost should be shown as `n/a`. + */ +export function resolvePricing( + model: string, + userOverrides: Record, +): PricingEntry | null { + const overrideEntries = Object.entries(userOverrides) as [string, PricingEntry][]; + if (overrideEntries.length > 0) { + const match = longestSubstringMatch(model, overrideEntries); + if (match) return match; + } + return longestSubstringMatch(model, DEFAULT_PRICING_TABLE); +} + +/** + * Resolve context window size for `model`. Always returns a number (fallback: 128_000). + */ +export function resolveContextWindow( + model: string, + userOverrides: Record, +): number { + const overrideEntries = Object.entries(userOverrides) as [string, number][]; + if (overrideEntries.length > 0) { + const match = longestSubstringMatch(model, overrideEntries); + if (match !== null) return match; + } + return longestSubstringMatch(model, DEFAULT_CONTEXT_WINDOW_TABLE) ?? FALLBACK_CONTEXT_WINDOW; +} + +/** + * Estimate cost in USD. Returns null when pricing is null (no entry for model). + */ +export function estimateCost( + input: number, + output: number, + pricing: PricingEntry | null, +): number | null { + if (!pricing) return null; + return (input * pricing.inputPerMToken + output * pricing.outputPerMToken) / 1_000_000; +} +``` + +- [ ] **Step 4: Run to confirm pass** + +```bash +/home/kyuna/Desktop/inaricode/node_modules/.bin/vitest run packages/cli/test/token-pricing.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 5: Run full suite** + +```bash +/home/kyuna/Desktop/inaricode/node_modules/.bin/vitest run +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/utils/token-pricing.ts packages/cli/test/token-pricing.test.ts +git commit -m "feat(utils): add token pricing tables and context window resolution" +``` + +--- + +### Task 3: Config — `tokenHints` key + +**Files:** +- Modify: `packages/cli/src/config.ts` +- Modify: `packages/cli/test/config-keys.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Add inside the existing `describe("config keys (inaricode.yaml)", ...)` block in `packages/cli/test/config-keys.test.ts`: + +```typescript + it("loads tokenHints with explicit values", async () => { + const dir = mkdtempSync(join(tmpdir(), "inari-th-")); + try { + vi.stubEnv("ANTHROPIC_API_KEY", "sk-test"); + writeFileSync( + join(dir, "inaricode.yaml"), + `provider: anthropic +tokenHints: + enabled: false + pricing: + my-model: + inputPerMToken: 1.0 + outputPerMToken: 5.0 + contextWindow: + my-model: 32000 +`, + "utf8", + ); + const cfg = await loadConfig(dir); + expect(cfg.tokenHints.enabled).toBe(false); + expect(cfg.tokenHints.pricing["my-model"]).toEqual({ + inputPerMToken: 1.0, + outputPerMToken: 5.0, + }); + expect(cfg.tokenHints.contextWindow["my-model"]).toBe(32000); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("tokenHints defaults to enabled with empty override maps", async () => { + const dir = mkdtempSync(join(tmpdir(), "inari-th2-")); + try { + vi.stubEnv("ANTHROPIC_API_KEY", "sk-test"); + writeFileSync(join(dir, "inaricode.yaml"), `provider: anthropic\n`, "utf8"); + const cfg = await loadConfig(dir); + expect(cfg.tokenHints.enabled).toBe(true); + expect(cfg.tokenHints.pricing).toEqual({}); + expect(cfg.tokenHints.contextWindow).toEqual({}); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +``` + +- [ ] **Step 2: Run to confirm failure** + +```bash +/home/kyuna/Desktop/inaricode/node_modules/.bin/vitest run packages/cli/test/config-keys.test.ts +``` + +Expected: 2 new tests fail — `cfg.tokenHints` is undefined. + +- [ ] **Step 3: Add `tokenHints` to `RawConfigSchema` in `config.ts`** + +In `packages/cli/src/config.ts`, inside the `z.object({…})` in `RawConfigSchema`, add after the `summarization` block: + +```typescript + /** Token/cost hints in chat chrome and /tokens command. */ + tokenHints: z + .object({ + enabled: z.boolean().optional().default(true), + pricing: z + .record( + z.string(), + z.object({ + inputPerMToken: z.number().nonnegative(), + outputPerMToken: z.number().nonnegative(), + }), + ) + .optional() + .default({}), + contextWindow: z.record(z.string(), z.number().int().positive()).optional().default({}), + }) + .optional(), +``` + +- [ ] **Step 4: Add `tokenHints` to `InariConfig` type** + +In `packages/cli/src/config.ts`, inside the `InariConfig` type, add after `summarization`: + +```typescript + tokenHints: { + enabled: boolean; + pricing: Record; + contextWindow: Record; + }; +``` + +- [ ] **Step 5: Wire into both normalization return sites** + +Search for the two objects returned by `resolveConfigFromRaw` (both contain `summarization: {…}`). Add after each `summarization` block: + +```typescript + tokenHints: { + enabled: c.tokenHints?.enabled ?? true, + pricing: c.tokenHints?.pricing ?? {}, + contextWindow: c.tokenHints?.contextWindow ?? {}, + }, +``` + +- [ ] **Step 6: Run to confirm pass** + +```bash +/home/kyuna/Desktop/inaricode/node_modules/.bin/vitest run packages/cli/test/config-keys.test.ts +``` + +Expected: all 8 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/config.ts packages/cli/test/config-keys.test.ts +git commit -m "feat(config): add tokenHints config key with enabled/pricing/contextWindow" +``` + +--- + +### Task 4: Chrome rendering + REPL wiring + +**Files:** +- Modify: `packages/cli/src/ui/chat-chrome.ts` +- Modify: `packages/cli/src/ui/chat-repl.ts` + +No new test file — chrome rendering is validated manually. The REPL wiring produces no unit-testable side-effect beyond what config tests cover. + +- [ ] **Step 1: Add imports to `chat-chrome.ts`** + +At the top of `packages/cli/src/ui/chat-chrome.ts`, add after the existing imports: + +```typescript +import type { AgentHistoryItem } from "../llm/types.js"; +import { estimateHistoryTokens, formatTokenCount, type HistoryTokens } from "../utils/token-estimate.js"; +import { resolvePricing, resolveContextWindow, estimateCost, type PricingEntry } from "../utils/token-pricing.js"; +``` + +- [ ] **Step 2: Add `TokenHintState` + `renderTokenHintLine` to `chat-chrome.ts`** + +Append at the end of `packages/cli/src/ui/chat-chrome.ts`: + +```typescript +export type TokenHintState = { + tokens: HistoryTokens; + cost: number | null; + contextWindow: number; +}; + +/** Render the single-line token hint for REPL output. Returns an ANSI-colored string. */ +export function renderTokenHintLine( + state: TokenHintState, + plain: boolean, +): string { + const { tokens, cost, contextWindow } = state; + const pct = Math.min(100, Math.round((tokens.total / contextWindow) * 100)); + const filled = Math.round(pct / 10); + + const bar = plain + ? `[${pct}%]` + : `${"▓".repeat(filled)}${"░".repeat(10 - filled)} ${pct}%`; + + const totalFmt = formatTokenCount(tokens.total); + const windowFmt = formatTokenCount(contextWindow); + const costStr = cost !== null ? ` · $${cost.toFixed(3)}` : ""; + + const line = `~ ${totalFmt} / ${windowFmt} tokens${costStr} ${bar}`; + + if (plain || !ansiBase()) return line; + + const color = + pct >= 95 ? "\x1b[31m" : pct >= 80 ? "\x1b[33m" : "\x1b[2m"; + return `${color}${line}\x1b[0m`; +} + +/** + * Compute token hint from history + config and render it. + * Wraps computation in try/catch — must never crash the agent loop. + * Returns null on any error or when tokenHints is disabled. + */ +export function computeTokenHintLine( + history: AgentHistoryItem[], + model: string, + tokenHintsConfig: { + enabled: boolean; + pricing: Record; + contextWindow: Record; + }, + plain: boolean, +): string | null { + if (!tokenHintsConfig.enabled) return null; + try { + const tokens = estimateHistoryTokens(history); + const pricing = resolvePricing(model, tokenHintsConfig.pricing); + const cost = estimateCost(tokens.input, tokens.output, pricing); + const contextWindow = resolveContextWindow(model, tokenHintsConfig.contextWindow); + return renderTokenHintLine({ tokens, cost, contextWindow }, plain); + } catch { + return null; + } +} +``` + +- [ ] **Step 3: Wire hint into `chat-repl.ts`** + +In `packages/cli/src/ui/chat-repl.ts`, add `computeTokenHintLine` to the import from `./chat-chrome.js`: + +```typescript +import { + computeTokenHintLine, + formatReplSessionWelcome, + replAssistantLead, + replPrompt, + replTurnSeparator, + replUserBlock, + useChatAnsi, +} from "./chat-chrome.js"; +``` + +Then, after `history = next;` (around line 179), add the hint output before `replTurnSeparator`: + +```typescript + history = next; + if (useStream) { + output.write("\n"); + } else { + output.write(`${replAssistantLead(plain, cfg.chatTheme)}${assistantText}\n`); + } + const hint = computeTokenHintLine(history, cfg.model, cfg.tokenHints, plain); + if (hint) output.write(`${hint}\n`); + output.write(replTurnSeparator(plain, cfg.chatTheme)); +``` + +- [ ] **Step 4: TypeScript check** + +```bash +node_modules/.bin/tsc -p packages/cli/tsconfig.json --noEmit +``` + +Expected: zero errors. + +- [ ] **Step 5: Run full suite** + +```bash +/home/kyuna/Desktop/inaricode/node_modules/.bin/vitest run +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/ui/chat-chrome.ts packages/cli/src/ui/chat-repl.ts +git commit -m "feat(ui): add token hint chrome line to REPL" +``` + +--- + +### Task 5: TUI footer + +**Files:** +- Modify: `packages/cli/src/ui/chat-tui.tsx` + +- [ ] **Step 1: Add `tokenHint` state** + +In `packages/cli/src/ui/chat-tui.tsx`, add `computeTokenHintLine` to the import from `./chat-chrome.js`: + +```typescript +import { buildTuiChromeLines, computeTokenHintLine, tuiAccentColor } from "./chat-chrome.js"; +``` + +In `ChatTuiInner`, add state after the existing `useState` declarations (around line 98): + +```typescript + const [tokenHint, setTokenHint] = useState(""); +``` + +- [ ] **Step 2: Update hint after each turn** + +In `ChatTuiInner`, after `setHistory(next);` (around line 197), add: + +```typescript + setHistory(next); + const hint = computeTokenHintLine(next, props.cfg.model, props.cfg.tokenHints, plain); + setTokenHint(hint ?? ""); + await persist(next); +``` + +Also add `props.cfg.model` and `props.cfg.tokenHints` to the `useCallback` dependency array (around line 208–232): + +```typescript + props.cfg.model, + props.cfg.tokenHints, +``` + +- [ ] **Step 3: Render footer in the main return block** + +In `ChatTuiInner`'s main `return` (around line 299), add the hint line between the transcript box and the input/busy line: + +```typescript + return ( + + {chromePanel} + + {transcript} + {streaming ? ( + + assistant + {streaming} + + ) : null} + + {tokenHint ? {tokenHint} : null} + {busy ? ( + plain ? ( + {tr(loc, "tuiBusy")} + ) : ( + {tr(loc, "tuiBusy")} + ) + ) : ( + + {plain ? "> " : "› "} + { + void onSubmit(value); + }} + /> + + )} + + ); +``` + +- [ ] **Step 4: TypeScript check** + +```bash +node_modules/.bin/tsc -p packages/cli/tsconfig.json --noEmit +``` + +Expected: zero errors. + +- [ ] **Step 5: Run full suite** + +```bash +/home/kyuna/Desktop/inaricode/node_modules/.bin/vitest run +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/ui/chat-tui.tsx +git commit -m "feat(ui): add persistent token hint footer to TUI" +``` + +--- + +### Task 6: `/tokens` slash command + +**Files:** +- Modify: `packages/cli/src/ui/chat-slash.ts` +- Modify: `packages/cli/src/ui/chat-repl.ts` +- Modify: `packages/cli/src/ui/chat-tui.tsx` +- Modify: `packages/cli/src/i18n/strings.ts` +- Create: `packages/cli/test/slash-tokens.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +// packages/cli/test/slash-tokens.test.ts +import { describe, expect, it } from "vitest"; +import { handleChatSlashInput } from "../src/ui/chat-slash.js"; +import type { AgentHistoryItem, LLMProvider } from "../src/llm/types.js"; + +function u(t: string): AgentHistoryItem { return { kind: "user_text", text: t }; } +function a(t: string): AgentHistoryItem { + return { kind: "assistant", blocks: [{ type: "text", text: t }] }; +} + +function mockProvider(): LLMProvider { + return { + async complete() { + return { stopReason: "end_turn", blocks: [{ type: "text", text: "ok" }] }; + }, + }; +} + +function makeCtx( + trimmed: string, + history: AgentHistoryItem[], + model = "claude-sonnet-4", + tokenHintsEnabled = true, +) { + const written: string[] = []; + return { + ctx: { + locale: "en" as const, + cwd: "/tmp", + workspaceRoot: "/tmp", + trimmed, + getHistory: () => history, + setHistory: () => {}, + persistHistory: async () => {}, + write: async (s: string) => { written.push(s); }, + persistEmpty: async () => {}, + provider: mockProvider(), + summarization: { enabled: false, threshold: 120_000, keepRecentTurns: 4 }, + model, + tokenHintsConfig: { + enabled: tokenHintsEnabled, + pricing: {}, + contextWindow: {}, + }, + }, + written, + }; +} + +describe("/tokens", () => { + it("returns kind: again", async () => { + const { ctx } = makeCtx("/tokens", [u("hello"), a("world")]); + const result = await handleChatSlashInput(ctx); + expect(result.kind).toBe("again"); + }); + + it("outputs session totals header", async () => { + const { ctx, written } = makeCtx("/tokens", [u("hello"), a("world")]); + await handleChatSlashInput(ctx); + const output = written.join(""); + expect(output).toContain("Session tokens (estimated)"); + expect(output).toContain("User + tool input"); + expect(output).toContain("Assistant output"); + expect(output).toContain("Total"); + }); + + it("outputs context window line", async () => { + const { ctx, written } = makeCtx("/tokens", [u("hello"), a("world")]); + await handleChatSlashInput(ctx); + const output = written.join(""); + expect(output).toContain("Context window:"); + expect(output).toContain("200k"); // claude default + }); + + it("outputs per-turn table header", async () => { + const history: AgentHistoryItem[] = [u("q1"), a("a1"), u("q2"), a("a2")]; + const { ctx, written } = makeCtx("/tokens", history); + await handleChatSlashInput(ctx); + const output = written.join(""); + expect(output).toContain("Turn"); + expect(output).toContain("Input"); + expect(output).toContain("Output"); + expect(output).toContain("Cost"); + }); + + it("shows n/a cost for unknown model", async () => { + const history: AgentHistoryItem[] = [u("hello"), a("world")]; + const { ctx, written } = makeCtx("/tokens", history, "my-unknown-model-xyz"); + await handleChatSlashInput(ctx); + const output = written.join(""); + expect(output).toContain("n/a"); + }); + + it("works even when tokenHints.enabled is false", async () => { + const { ctx, written } = makeCtx("/tokens", [u("hello"), a("world")], "claude-sonnet-4", false); + const result = await handleChatSlashInput(ctx); + expect(result.kind).toBe("again"); + expect(written.join("")).toContain("Session tokens"); + }); +}); +``` + +- [ ] **Step 2: Run to confirm failure** + +```bash +/home/kyuna/Desktop/inaricode/node_modules/.bin/vitest run packages/cli/test/slash-tokens.test.ts +``` + +Expected: tests fail — `SlashCtx` missing `model` + `tokenHintsConfig`, `/tokens` not handled. + +- [ ] **Step 3: Add imports + fields to `SlashCtx` in `chat-slash.ts`** + +In `packages/cli/src/ui/chat-slash.ts`, add to the imports at the top: + +```typescript +import { estimateHistoryTokens, formatTokenCount } from "../utils/token-estimate.js"; +import { resolvePricing, resolveContextWindow, estimateCost, type PricingEntry } from "../utils/token-pricing.js"; +``` + +Add fields to `SlashCtx` (after `summarization`): + +```typescript + /** Active model name — used for /tokens cost calculation. */ + model: string; + tokenHintsConfig: { + enabled: boolean; + pricing: Record; + contextWindow: Record; + }; +``` + +- [ ] **Step 4: Add `/tokens` handler in `handleChatSlashInput`** + +In `packages/cli/src/ui/chat-slash.ts`, add before the `slashUnknown` fallback at the bottom: + +```typescript + if (cmd === "tokens") { + const history = ctx.getHistory(); + const tokens = estimateHistoryTokens(history); + const pricing = resolvePricing(ctx.model, ctx.tokenHintsConfig.pricing); + const cost = estimateCost(tokens.input, tokens.output, pricing); + const contextWindow = resolveContextWindow(ctx.model, ctx.tokenHintsConfig.contextWindow); + const pct = Math.min(100, Math.round((tokens.total / contextWindow) * 100)); + + const fmtCost = (c: number | null) => (c !== null ? `$${c.toFixed(3)}` : "n/a"); + const inputCost = pricing + ? fmtCost((tokens.input * pricing.inputPerMToken) / 1_000_000) + : "n/a"; + const outputCost = pricing + ? fmtCost((tokens.output * pricing.outputPerMToken) / 1_000_000) + : "n/a"; + + let report = `Session tokens (estimated)\n`; + report += ` User + tool input : ~${formatTokenCount(tokens.input).padStart(5)} ${inputCost}\n`; + report += ` Assistant output : ~${formatTokenCount(tokens.output).padStart(5)} ${outputCost}\n`; + report += ` ${"─".repeat(33)}\n`; + report += ` Total : ~${formatTokenCount(tokens.total).padStart(5)} ${fmtCost(cost)}\n`; + report += `\n`; + report += `Context window: ~${formatTokenCount(tokens.total)} / ${formatTokenCount(contextWindow)} (${pct}%)\n`; + + const recentTurns = tokens.byTurn.slice(-10); + if (recentTurns.length > 0) { + report += `\nTurn Input Output Cost\n`; + for (const t of recentTurns) { + const turnCost = pricing + ? fmtCost( + (t.input * pricing.inputPerMToken + t.output * pricing.outputPerMToken) / + 1_000_000, + ) + : "n/a"; + report += `${String(t.turn).padStart(4)} ~${formatTokenCount(t.input).padStart(5)} ~${formatTokenCount(t.output).padStart(5)} ${turnCost}\n`; + } + } + + await ctx.write(report); + return { kind: "again" }; + } +``` + +- [ ] **Step 5: Add `/tokens` to help text** + +In `packages/cli/src/i18n/strings.ts`, update both EN and MN `slashHelp` strings to include `/tokens`: + +EN (around line 69): +```typescript + slashHelp: + "Commands: /help /pick /clear /compact [n] /compact summary /tokens /exit (aliases: /h /? · /cls · /trim)\nAlso: exit quit гарах", +``` + +MN (around line 192): +```typescript + slashHelp: + "Тушаалууд: /help /pick /clear /compact [n] /compact summary /tokens /exit (хочилсон нэрс: /h /? · /cls · /trim)\nМөн: exit quit гарах", +``` + +- [ ] **Step 6: Pass `model` + `tokenHintsConfig` from `chat-repl.ts`** + +In `packages/cli/src/ui/chat-repl.ts`, add to the `handleChatSlashInput` call (after `summarization: cfg.summarization`): + +```typescript + model: cfg.model, + tokenHintsConfig: cfg.tokenHints, +``` + +- [ ] **Step 7: Pass `model` + `tokenHintsConfig` from `chat-tui.tsx`** + +In `packages/cli/src/ui/chat-tui.tsx`, add to the `handleChatSlashInput` call (after `summarization: props.cfg.summarization`): + +```typescript + model: props.cfg.model, + tokenHintsConfig: props.cfg.tokenHints, +``` + +Also add `props.cfg.tokenHints` to the `useCallback` dependency array. + +- [ ] **Step 8: Run tests to confirm pass** + +```bash +/home/kyuna/Desktop/inaricode/node_modules/.bin/vitest run packages/cli/test/slash-tokens.test.ts +``` + +Expected: all 6 tests pass. + +- [ ] **Step 9: TypeScript check + full suite** + +```bash +node_modules/.bin/tsc -p packages/cli/tsconfig.json --noEmit && /home/kyuna/Desktop/inaricode/node_modules/.bin/vitest run +``` + +Expected: zero TS errors, all tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add packages/cli/src/ui/chat-slash.ts packages/cli/src/ui/chat-repl.ts packages/cli/src/ui/chat-tui.tsx packages/cli/src/i18n/strings.ts packages/cli/test/slash-tokens.test.ts +git commit -m "feat(ui): add /tokens slash command with session breakdown and per-turn table" +``` + +--- + +## Self-Review + +### Spec coverage + +| Spec requirement | Task | +|---|---| +| `token-estimate.ts` with `estimateTokens`, `estimateHistoryTokens`, `formatTokenCount` | Task 1 | +| `token-pricing.ts` with bundled tables, `resolvePricing`, `resolveContextWindow`, `estimateCost` | Task 2 | +| `tokenHints` config key (`enabled`, `pricing`, `contextWindow`) | Task 3 | +| REPL chrome line after each turn | Task 4 | +| TUI persistent footer | Task 5 | +| `/tokens` command — role breakdown + per-turn table | Task 6 | +| `tokenHints.enabled: false` hides chrome but `/tokens` still works | Task 4 (`computeTokenHintLine` returns null) + Task 6 (reads history directly) | +| Color thresholds (≥80% yellow, ≥95% red) | Task 4 (`renderTokenHintLine`) | +| Plain mode bar replacement | Task 4 (`renderTokenHintLine`) | +| Error handling (never crash loop) | Task 4 (`computeTokenHintLine` try/catch) | + +### Placeholder scan + +No TBDs or vague steps. All code is complete. + +### Type consistency + +- `PricingEntry` defined once in `token-pricing.ts`, imported in `chat-chrome.ts` and `chat-slash.ts` +- `TokenHintState` defined once in `chat-chrome.ts` +- `HistoryTokens` / `TurnTokens` defined once in `token-estimate.ts` +- `tokenHintsConfig` shape in `SlashCtx` matches `InariConfig.tokenHints` shape exactly From 17d8dad75bc268c8fc433500919e77b343942f78 Mon Sep 17 00:00:00 2001 From: kyuna0312 Date: Sat, 25 Apr 2026 18:19:26 +0900 Subject: [PATCH 4/5] add neovim and tmux integration from opencode --- packages/cli/src/ide/index.ts | 2 + packages/cli/src/ide/neovim.ts | 78 +++++++++++++++++++ packages/cli/src/ide/tmux.ts | 137 +++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 packages/cli/src/ide/index.ts create mode 100644 packages/cli/src/ide/neovim.ts create mode 100644 packages/cli/src/ide/tmux.ts diff --git a/packages/cli/src/ide/index.ts b/packages/cli/src/ide/index.ts new file mode 100644 index 0000000..18c8db2 --- /dev/null +++ b/packages/cli/src/ide/index.ts @@ -0,0 +1,2 @@ +export * as Neovim from "./neovim.js"; +export * as Tmux from "./tmux.js"; diff --git a/packages/cli/src/ide/neovim.ts b/packages/cli/src/ide/neovim.ts new file mode 100644 index 0000000..f82d0bc --- /dev/null +++ b/packages/cli/src/ide/neovim.ts @@ -0,0 +1,78 @@ +import { spawn, execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const execFileAsync = promisify(execFile); + +export class NeovimError extends Error { + readonly _tag = "NeovimError"; + constructor(message: string) { + super(message); + this.name = "NeovimError"; + } +} + +export class NotFoundError extends Error { + readonly _tag = "NotFoundError"; + constructor() { + super("Neovim not found"); + this.name = "NotFoundError"; + } +} + +export async function find(): Promise { + for (const cmd of ["nvim", "vim", "neovim", "nvim-linux64", "appimage"]) { + try { + const { stdout } = await execFileAsync("which", [cmd]); + if (stdout.trim()) { + return stdout.trim(); + } + } catch { + continue; + } + } + return undefined; +} + +export async function edit( + content: string, + opts?: { filename?: string; language?: string }, +): Promise { + const editor = await find(); + if (!editor) throw new NotFoundError(); + + const filepath = + opts?.filename ?? + join( + tmpdir(), + `${Date.now()}${opts?.language ? `.${opts.language}` : ".txt"}`, + ); + + const fs = await import("node:fs/promises"); + await fs.writeFile(filepath, content); + + try { + const child = spawn( + editor, + process.platform === "win32" + ? [filepath] + : ["--headless", "-es", filepath], + { + stdio: "inherit", + shell: true, + }, + ); + + await new Promise((resolve) => { + child.on("exit", () => resolve()); + }); + + const result = await fs.readFile(filepath, "utf-8"); + return result; + } finally { + if (!opts?.filename) { + await fs.unlink(filepath).catch(() => {}); + } + } +} diff --git a/packages/cli/src/ide/tmux.ts b/packages/cli/src/ide/tmux.ts new file mode 100644 index 0000000..667d76d --- /dev/null +++ b/packages/cli/src/ide/tmux.ts @@ -0,0 +1,137 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export class TmuxError extends Error { + readonly _tag = "TmuxError"; + constructor(message: string) { + super(message); + this.name = "TmuxError"; + } +} + +export class NotFoundError extends Error { + readonly _tag = "NotFoundError"; + constructor() { + super("Tmux not found"); + this.name = "NotFoundError"; + } +} + +export async function find(): Promise { + try { + const { stdout } = await execFileAsync("which", ["tmux"]); + return stdout.trim() || undefined; + } catch { + return undefined; + } +} + +export async function newSession( + name: string, + command?: string[], +): Promise { + const tmux = await find(); + if (!tmux) throw new NotFoundError(); + + const args = ["new-session", "-d", "-s", name]; + if (command) { + args.push("-c", command[0], "--"); + args.push(...command.slice(1)); + } + + try { + await execFileAsync(tmux, args); + } catch (e) { + const err = e as { message?: string }; + throw new TmuxError(err.message ?? "Failed to create session"); + } +} + +export async function sendKeys( + sessionName: string, + keys: string, +): Promise { + const tmux = await find(); + if (!tmux) throw new NotFoundError(); + + try { + await execFileAsync(tmux, ["send-keys", "-t", sessionName, keys]); + } catch (e) { + const err = e as { message?: string }; + throw new TmuxError(err.message ?? "Failed to send keys"); + } +} + +export async function sendCommand( + sessionName: string, + command: string, +): Promise { + const tmux = await find(); + if (!tmux) throw new NotFoundError(); + + try { + await execFileAsync(tmux, [ + "send-keys", + "-t", + sessionName, + command, + "Enter", + ]); + } catch (e) { + const err = e as { message?: string }; + throw new TmuxError(err.message ?? "Failed to send command"); + } +} + +export async function capturePane(sessionName: string): Promise { + const tmux = await find(); + if (!tmux) throw new NotFoundError(); + + try { + const { stdout } = await execFileAsync(tmux, [ + "capture-pane", + "-t", + sessionName, + "-p", + ]); + return stdout; + } catch (e) { + const err = e as { message?: string }; + throw new TmuxError(err.message ?? "Failed to capture pane"); + } +} + +export async function killSession(sessionName: string): Promise { + const tmux = await find(); + if (!tmux) throw new NotFoundError(); + + try { + await execFileAsync(tmux, ["kill-session", "-t", sessionName]); + } catch (e) { + const err = e as { message?: string }; + throw new TmuxError(err.message ?? "Failed to kill session"); + } +} + +export async function listSessions(): Promise { + const tmux = await find(); + if (!tmux) return []; + + try { + const { stdout } = await execFileAsync(tmux, [ + "list-sessions", + "-F", + "#{session_name}", + ]); + return stdout.trim().split("\n").filter(Boolean); + } catch { + return []; + } +} + +export async function hasSession(name: string): Promise { + const sessions = await listSessions(); + return sessions.includes(name); +} From 7bfdd66e5e1141d054b0a177909dffcc80cd9290 Mon Sep 17 00:00:00 2001 From: kyuna0312 Date: Sat, 25 Apr 2026 18:45:19 +0900 Subject: [PATCH 5/5] refactor: full rebuild - OpenCode + nyanvim integration structure - Delete legacy CLI structure (60+ files) - Create minimal 6-file structure (cli, config, providers, opencode, nyanvim, version) - Add OpenCode SDK client - Add Neovim/nyan.nvim detection - Update README.md (202 -> 75 lines) - Add PROJECT.md documentation - Add integration plans BREAKING: Simplified CLI from 1500+ to ~400 lines --- .remember/tmp/save-session.pid | 2 +- CLAUDE.md | 12 + README.md | 214 +---- docs/PROJECT.md | 166 ++++ docs/plan/opencode-nyanvim-integration.md | 299 +++++++ docs/plan/structure-refactor.md | 164 ++++ .../2026-04-17-llm-context-summarization.md | 836 ++++++++++++++++++ packages/cli/src/agent/loop.ts | 147 --- packages/cli/src/agent/system-prompt.ts | 11 - packages/cli/src/cli.ts | 404 +++------ packages/cli/src/completion/render.ts | 265 ------ packages/cli/src/config-paths.ts | 29 - packages/cli/src/config.ts | 789 ++--------------- packages/cli/src/cursor-api/http.ts | 55 -- packages/cli/src/cursor-api/run-cursor-cli.ts | 243 ----- packages/cli/src/engine/client.ts | 151 ---- packages/cli/src/fuzzy/match.ts | 39 - packages/cli/src/i18n/locale.ts | 40 - packages/cli/src/i18n/prompts.ts | 40 - packages/cli/src/i18n/strings.ts | 267 ------ packages/cli/src/ide/index.ts | 2 - packages/cli/src/ide/neovim.ts | 78 -- packages/cli/src/ide/tmux.ts | 137 --- packages/cli/src/llm/anthropic.ts | 155 ---- packages/cli/src/llm/create-provider.ts | 15 - packages/cli/src/llm/inari-tools.ts | 192 ---- packages/cli/src/llm/openai-compatible.ts | 184 ---- packages/cli/src/llm/types.ts | 32 - packages/cli/src/mcp/run-mcp-cli.ts | 19 - packages/cli/src/mcp/stdio-mcp.ts | 168 ---- packages/cli/src/media/hf-image.ts | 74 -- packages/cli/src/media/run-media.ts | 62 -- packages/cli/src/nyanvim.ts | 63 ++ packages/cli/src/observability/json-log.ts | 13 - packages/cli/src/opencode.ts | 175 ++++ packages/cli/src/pick/collect-files.ts | 38 - packages/cli/src/pick/fzf.ts | 29 - packages/cli/src/pick/pick-tui.tsx | 86 -- packages/cli/src/pick/run-pick.ts | 106 --- packages/cli/src/pkg-meta.ts | 80 -- packages/cli/src/policy/shell.ts | 47 - packages/cli/src/providers.ts | 59 ++ packages/cli/src/providers/catalog.ts | 79 -- .../cli/src/providers/run-providers-cli.ts | 41 - packages/cli/src/release-flowers.ts | 72 -- packages/cli/src/session/compact-history.ts | 31 - packages/cli/src/session/context-compact.ts | 148 ---- packages/cli/src/session/file-session.ts | 29 - packages/cli/src/session/summarize-history.ts | 80 -- packages/cli/src/sidecar/client.ts | 78 -- packages/cli/src/sidecar/resolve.ts | 35 - packages/cli/src/skills/load-pack.ts | 76 -- packages/cli/src/skills/manifest.ts | 14 - packages/cli/src/skills/read-pack-config.ts | 18 - packages/cli/src/skills/resolve-context.ts | 80 -- packages/cli/src/skills/run-skills-cli.ts | 42 - packages/cli/src/tools/embeddings-api.ts | 69 -- packages/cli/src/tools/engine-run.ts | 287 ------ packages/cli/src/tools/redact.ts | 20 - packages/cli/src/tools/semantic-search.ts | 232 ----- packages/cli/src/tools/symbol-outline-ast.ts | 96 -- packages/cli/src/tools/symbol-outline.ts | 116 --- packages/cli/src/ui/chat-chrome.ts | 187 ---- packages/cli/src/ui/chat-repl.ts | 192 ---- packages/cli/src/ui/chat-slash.ts | 120 --- packages/cli/src/ui/chat-tui.tsx | 384 -------- packages/cli/src/ui/git-branch.ts | 19 - packages/cli/src/ui/logo.ts | 78 -- packages/cli/src/utils/concurrency-pool.ts | 71 -- packages/cli/src/utils/config-cache.ts | 64 -- packages/cli/src/utils/env-validator.ts | 136 --- packages/cli/src/utils/retry-executor.ts | 101 --- packages/cli/src/version.ts | 16 + packages/cli/src/workspace-root.ts | 5 - packages/cli/test/token-estimate.test.ts | 103 +++ 75 files changed, 2127 insertions(+), 6979 deletions(-) create mode 100644 docs/PROJECT.md create mode 100644 docs/plan/opencode-nyanvim-integration.md create mode 100644 docs/plan/structure-refactor.md create mode 100644 docs/superpowers/plans/2026-04-17-llm-context-summarization.md delete mode 100644 packages/cli/src/agent/loop.ts delete mode 100644 packages/cli/src/agent/system-prompt.ts delete mode 100644 packages/cli/src/completion/render.ts delete mode 100644 packages/cli/src/config-paths.ts delete mode 100644 packages/cli/src/cursor-api/http.ts delete mode 100644 packages/cli/src/cursor-api/run-cursor-cli.ts delete mode 100644 packages/cli/src/engine/client.ts delete mode 100644 packages/cli/src/fuzzy/match.ts delete mode 100644 packages/cli/src/i18n/locale.ts delete mode 100644 packages/cli/src/i18n/prompts.ts delete mode 100644 packages/cli/src/i18n/strings.ts delete mode 100644 packages/cli/src/ide/index.ts delete mode 100644 packages/cli/src/ide/neovim.ts delete mode 100644 packages/cli/src/ide/tmux.ts delete mode 100644 packages/cli/src/llm/anthropic.ts delete mode 100644 packages/cli/src/llm/create-provider.ts delete mode 100644 packages/cli/src/llm/inari-tools.ts delete mode 100644 packages/cli/src/llm/openai-compatible.ts delete mode 100644 packages/cli/src/llm/types.ts delete mode 100644 packages/cli/src/mcp/run-mcp-cli.ts delete mode 100644 packages/cli/src/mcp/stdio-mcp.ts delete mode 100644 packages/cli/src/media/hf-image.ts delete mode 100644 packages/cli/src/media/run-media.ts create mode 100644 packages/cli/src/nyanvim.ts delete mode 100644 packages/cli/src/observability/json-log.ts create mode 100644 packages/cli/src/opencode.ts delete mode 100644 packages/cli/src/pick/collect-files.ts delete mode 100644 packages/cli/src/pick/fzf.ts delete mode 100644 packages/cli/src/pick/pick-tui.tsx delete mode 100644 packages/cli/src/pick/run-pick.ts delete mode 100644 packages/cli/src/pkg-meta.ts delete mode 100644 packages/cli/src/policy/shell.ts create mode 100644 packages/cli/src/providers.ts delete mode 100644 packages/cli/src/providers/catalog.ts delete mode 100644 packages/cli/src/providers/run-providers-cli.ts delete mode 100644 packages/cli/src/release-flowers.ts delete mode 100644 packages/cli/src/session/compact-history.ts delete mode 100644 packages/cli/src/session/context-compact.ts delete mode 100644 packages/cli/src/session/file-session.ts delete mode 100644 packages/cli/src/session/summarize-history.ts delete mode 100644 packages/cli/src/sidecar/client.ts delete mode 100644 packages/cli/src/sidecar/resolve.ts delete mode 100644 packages/cli/src/skills/load-pack.ts delete mode 100644 packages/cli/src/skills/manifest.ts delete mode 100644 packages/cli/src/skills/read-pack-config.ts delete mode 100644 packages/cli/src/skills/resolve-context.ts delete mode 100644 packages/cli/src/skills/run-skills-cli.ts delete mode 100644 packages/cli/src/tools/embeddings-api.ts delete mode 100644 packages/cli/src/tools/engine-run.ts delete mode 100644 packages/cli/src/tools/redact.ts delete mode 100644 packages/cli/src/tools/semantic-search.ts delete mode 100644 packages/cli/src/tools/symbol-outline-ast.ts delete mode 100644 packages/cli/src/tools/symbol-outline.ts delete mode 100644 packages/cli/src/ui/chat-chrome.ts delete mode 100644 packages/cli/src/ui/chat-repl.ts delete mode 100644 packages/cli/src/ui/chat-slash.ts delete mode 100644 packages/cli/src/ui/chat-tui.tsx delete mode 100644 packages/cli/src/ui/git-branch.ts delete mode 100644 packages/cli/src/ui/logo.ts delete mode 100644 packages/cli/src/utils/concurrency-pool.ts delete mode 100644 packages/cli/src/utils/config-cache.ts delete mode 100644 packages/cli/src/utils/env-validator.ts delete mode 100644 packages/cli/src/utils/retry-executor.ts create mode 100644 packages/cli/src/version.ts delete mode 100644 packages/cli/src/workspace-root.ts create mode 100644 packages/cli/test/token-estimate.test.ts diff --git a/.remember/tmp/save-session.pid b/.remember/tmp/save-session.pid index 2bbf4f6..0fe6df9 100644 --- a/.remember/tmp/save-session.pid +++ b/.remember/tmp/save-session.pid @@ -1 +1 @@ -91901 +75840 diff --git a/CLAUDE.md b/CLAUDE.md index 00fe686..3fe98a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ yarn workspace @inaricode/cli test -- test/config-keys.test.ts # Full pre-PR gate yarn verify # lint + build + test (@inaricode/cli) yarn verify:all # + cargo test + npm pack --dry-run +yarn pack:check # list files that would ship in @inaricode/cli ``` ## Architecture @@ -68,6 +69,8 @@ Non-workspace packages (not in Yarn graph): | `fuzzy/` | Fuzzy file picker (builtin or fzf) | | `mcp/` | Stdio MCP server | | `observability/` | `INARI_LOG=json` structured logging | +| `media/` | Hugging Face text-to-image (`inari media`) | +| `utils/` | Shared helpers: `concurrency-pool.ts`, `retry-executor.ts`, `env-validator.ts`, `config-cache.ts` | ### Data flow @@ -94,6 +97,14 @@ API keys: `keys.` in YAML, or env vars (`ANTHROPIC_API_KEY`, `OPENAI_A - Avoid non-null assertions (`!`); prefer `?.` / `??` and type narrowing - Unused parameters: prefix with `_` +## Python sidecar (optional) + +`packages/sidecar/inari_sidecar.py` — BM25 `codebase_search`. Enable: `pip install -r packages/sidecar/requirements.txt`. `inari doctor` reports sidecar status. + +## Version line + +`packages/cli/package.json` → `version` is semver. Flower codename: optional `"inaricode": { "codename": "Sakura" }` in that file; if absent, `src/release-flowers.ts` derives it deterministically from the version. `inari --version` and chat headers use `cliVersionLine()`: `vX.Y.Z · patch N · FlowerName`. + ## Rust engine - Source: `packages/engine/` (separate Cargo workspace) @@ -116,6 +127,7 @@ API keys: `keys.` in YAML, or env vars (`ANTHROPIC_API_KEY`, `OPENAI_A ## Important constraints +- `.inariignore` at repo root excludes paths from `grep` tool results (like `.gitignore` but for the agent) - Do **not** copy code from `kyuna0312/claude-code` or other external agent CLIs into this repo — architecture lessons only - Do **not** commit `tsc` output under `packages/cli/test/`; compiled output lives in `dist/` - Lint has `--max-warnings 0`; keep ESLint clean diff --git a/README.md b/README.md index 333d7ca..1f7f7f6 100644 --- a/README.md +++ b/README.md @@ -2,201 +2,75 @@ [![CI](https://github.com/kyuna0312/inaricode/actions/workflows/ci.yml/badge.svg)](https://github.com/kyuna0312/inaricode/actions/workflows/ci.yml) -**InariCode** is a local AI coding assistant CLI: multi-turn **chat**, **tool use** (read/write/grep/patch/shell), and a **Rust engine** for sandboxed filesystem and process work. It speaks **Anthropic** and **OpenAI-compatible** APIs (Claude, ChatGPT, Hugging Face router, Google Gemini, Kimi, Qwen, Ollama, etc.). Releases show **semver + patch + a flower codename** (see `packages/cli/package.json` → `inaricode.codename`, or auto from `src/release-flowers.ts`). +**InariCode** — OpenCode-powered CLI coding assistant with nyan.nvim integration. ## Features -- **`inari chat`** — REPL or **`--tui`** (Ink): streaming, **`--session`**, **`--plain`**, **`--provider`** / **`--model`** (override config), slash commands (**`/pick`** runs the fuzzy file picker); optional **git branch** in the header -- **`inari providers`** — list **Anthropic, ChatGPT, Kimi, Ollama, Groq, Gemini, HF, …** + **Cursor** (usage row); switch via config, **`INARI_PROVIDER`** / **`INARI_MODEL`**, or chat flags -- **`inari pick`** — **fuzzy** file chooser (built-in or **`fzf`**); respects `.gitignore` / `.inariignore` -- **`inari mcp`** — **stdio MCP** server exposing read-only engine tools (`read_file`, `list_dir`, `grep`) for external clients — see **[`docs/integrations/mcp.md`](docs/integrations/mcp.md)** -- **`inari completion`** — **`zsh`**, **`fish`**, **`bash`** completion scripts -- **`inari doctor`** — engine IPC, optional Python sidecar, embeddings check; in a monorepo checkout, prints **`packages/skills/examples`** when present -- **`inari cursor`** — Cursor **Cloud Agents API** (`CURSOR_API_KEY`): list/status/launch agents, models, etc. -- **`inari init`** / **`inari logo`** — starter config (**`--template beginner`** for read-only + soft theme) and branding -- **`inari skills list`** — validate **`skills.packs`** from config (declarative prompts + tool allowlists) — see **[`docs/skills.md`](docs/skills.md)** -- **`inari media`** — Hugging Face text-to-image (and video guidance stub) -- **Rust `inaricode-engine`** — JSON-line IPC + **Node native** bindings for the same dispatch -- **Optional** — Python sidecar (`codebase_search`), semantic search via embeddings API, `.inariignore` for grep -- **Debug** — **`INARI_LOG=json`** — structured JSON lines on stderr from the agent loop (no ANSI) +- **`inari chat`** — REPL or TUI mode with OpenCode as default provider +- **`inari pick`** — Fuzzy file picker (builtin or fzf) +- **`inari doctor`** — System check with nyan.nvim detection +- **Multi-provider** — OpenCode, Anthropic, OpenAI, Kimi, Ollama, Groq, Google Gemini +- **Neovim/nyan.nvim aware** — Detects running environment and plugin presence +- **Rust engine** — Sandboxed filesystem operations -UI strings: **English** and **Mongolian** (`locale` / `INARI_LANG`). - -## Cursor IDE & Cloud API - -- **`.cursor/`** is **gitignored**; keep rules local. Example snippets: **[`docs/integrations/cursor-rules.example.md`](docs/integrations/cursor-rules.example.md)**. -- **`yarn cli cursor`** talks to Cursor’s **Cloud Agents API** when **`CURSOR_API_KEY`** is set — see **[`docs/integrations/cursor.md`](docs/integrations/cursor.md)**. -- **`AGENTS.md`** and terminal **`yarn cli`** still apply. **MCP** roadmap: [plan](docs/plan/inari-code-plan.md). - -## Requirements - -| Component | Notes | -|-----------|--------| -| **Node.js** | ≥ 20 | -| **Yarn** | Classic v1 (see root `packageManager` in `package.json`) | -| **Rust** | Stable toolchain + **Cargo** (for `packages/engine` and optional native build) | - -**Maintainers:** **[`docs/publishing.md`](docs/publishing.md)** — pack contents, manual **`npm publish`**, and **tag-triggered** **[`publish.yml`](.github/workflows/publish.yml)** (needs **`NPM_TOKEN`** in repo secrets). - -## Repository layout - -| Path | Role | -|------|------| -| **`packages/cli/`** | TypeScript **`inari`** CLI (Ink TUI, LLM drivers, tools, config). Built output: **`dist/`**. | -| **`packages/engine/`** | Rust **`inaricode-engine`** binary — JSON-line IPC, sandboxed fs/grep/patch/shell. | -| **`packages/engine-native/`** | Optional **napi-rs** bindings (multi-target); CI often uses subprocess IPC instead. | -| **`packages/sidecar/`** | Optional Python BM25 helper for **`codebase_search`** (`pip install -r requirements.txt`). | -| **`packages/tasks/`** | Task **templates** for contributors / future automation — **[`packages/tasks/README.md`](packages/tasks/README.md)**. | -| **`packages/skills/`** | Declarative **skill packs** (loaded via **`skills.packs`**) — **[`packages/skills/README.md`](packages/skills/README.md)**, **[`docs/skills.md`](docs/skills.md)**. | -| **`docs/plan/`** | Roadmap **[`inari-code-plan.md`](docs/plan/inari-code-plan.md)** and working **[`TASKS.md`](docs/plan/TASKS.md)**. | -| **`docs/integrations/`** | Cursor and other IDE / host integration notes. | -| **`docs/research/`** | Agent-CLI **comparison** & **supply-chain** notes (educational links only; no third-party code). | -| **`docs/engine-platform.md`** | **IPC** modes, **napi-rs** targets, **`INARI_ENGINE_*`** env vars. | -| **`docs/engine-profiling.md`** | **Profiling budget** for the Rust engine (before any mmap/C++ path). | -| **`docs/publishing.md`** | **npm publish** checklist for **`@inaricode/cli`**. | -| **`packages/README.md`** | Index of every **`packages/*`** directory. | -| Root **`eslint.config.mjs`**, **`turbo.json`** | Lint + Turborepo task graph (see **`package.json`** scripts). | - -## Quick start +## Quick Start ```bash git clone https://github.com/kyuna0312/inaricode.git cd inaricode yarn install -``` - -### Contributor check (~5 minutes) - -From a **clean clone**, on **Node ≥ 20**, **Yarn classic v1**, and **Rust stable**: - -1. `yarn install` -2. `yarn build` — builds **debug** `inaricode-engine` + TypeScript CLI (`packages/cli/dist`) -3. `yarn cli doctor` — expect **engine ipc: ok** (subprocess or native) - -Optional full gate before a PR: **`yarn verify`**. Before a release: **`yarn verify:all`**. **Engine / IPC matrix** (subprocess vs native, targets): **[`docs/engine-platform.md`](docs/engine-platform.md)**. - -Build the Rust engine (dev) and the CLI: - -```bash yarn build ``` -Or separately: - -```bash -yarn build:engine:dev # packages/engine -yarn build:cli # TypeScript via [Turborepo](https://turbo.build) → packages/cli/dist -yarn build:native # optional: @inaricode/engine-native (napi); CI uses subprocess IPC instead -``` - -Run the CLI from the workspace (after **`yarn install`** + **`yarn build`**). The name **`inari`** is only on your **`PATH`** if you **`yarn link`** inside `packages/cli` or install a published package — otherwise use one of these from the **repo root**: - -```bash -yarn cli --help -yarn cli init -yarn cli doctor - -# same binaries Yarn installs locally: -./node_modules/.bin/inari doctor -``` - -To type **`inari`** anywhere (global link for development): - -```bash -cd packages/cli && yarn link -# then: inari doctor -``` - -### Engine binary - -`inari doctor` expects a built engine or **`INARI_ENGINE_PATH`** pointing at the `inaricode-engine` binary. Build release with: - -```bash -yarn build:engine -``` - -## Configuration - -Cosmiconfig searches for **`inaricode`** under the current working directory, in this order: - -- If **`INARI_PROFILE`** or **`INARICODE_PROFILE`** is set (alphanumeric, `_`, `-` only), **`inaricode..yaml`** / **`.yml`** are tried **first**, then the list below. -- `inaricode.yaml` / `inaricode.yml` (recommended — use a `keys:` map per provider) -- `inaricode.config.cjs` / `.mjs` / `.js` -- `.inaricoderc.json` / `.yaml` / `.yml` - -Run **`inari init`** (default) to create **`inaricode.yaml`** with a `keys:` section, or **`inari init --format cjs`** for `inaricode.config.cjs`. - -If both YAML and `.cjs` exist, the YAML file wins (it is listed first). Set API keys under `keys.` or `apiKey`, or use env vars (see the template comments). Do not commit secrets. - -### Language (English / Mongolian) - -- Config: `locale: 'en'` or `locale: 'mn'` -- Override: **`INARI_LANG=en`** or **`INARI_LANG=mn`** (wins over config) - ## Commands -| Command | Purpose | -|---------|---------| -| `inari chat` | Start chat (add `--tui` for terminal UI) | -| `inari doctor` | Verify engine, sidecar, embeddings; bundled **skills** example dir (dev tree only) | -| `inari init` | Write example **`inaricode.yaml`** (`--format cjs` for `inaricode.config.cjs`) | -| `inari logo` | ASCII banner + bundled mascot path | -| `inari media image` | Text-to-image via **Hugging Face Inference API** (`HF_TOKEN`) | -| `inari media video` | Prints guidance (text-to-video is vendor-specific; not bundled yet) | -| `inari pick` | **Fuzzy file picker** (Ink UI, or **`fzf`** if configured / on PATH) — prints one absolute path | -| `inari mcp` | **Stdio MCP** for Cursor / Claude Desktop–style hosts (`--root` for workspace) | -| `inari completion` | Print **`zsh`**, **`fish`**, or **`bash`** completion script (pipe to `source` / `eval`) | +| Command | Description | +|---------|-------------| +| `inari chat` | Start REPL/TUI chat | +| `inari pick` | Fuzzy file picker | +| `inari doctor` | System check | +| `inari providers` | List providers | +| `inari init` | Create config | +| `inari logo` | Print logo | -Common flags: **`--root`**, **`--yes`** (skip confirms), **`--session`**, **`--no-stream`**, **`--read-only`**, **`--tui`**, **`--plain`** (no ANSI; calmer TUI — or **`INARI_PLAIN=1`**). +## Configuration -In chat, slash commands: **`/help`**, **`/pick`** (fuzzy file → next message is the relative path), **`/clear`**, **`/compact [n]`** (trim session to the last *n* user turns, default 8; alias **`/trim`**), **`/exit`** (plus `exit`, `quit`, `гарах`). With **`--session`**, `/clear` and `/compact` update the session file. +```yaml +# inaricode.yaml +provider: opencode +opencode: + enabled: true + url: "http://localhost:4096" + model: "claude-sonnet" +``` -### Fuzzy picker & shell completions (zsh / fish style) +## Providers -- **`inari pick`** — type to **fuzzy-filter** paths (↑/↓, Enter). Uses **`.gitignore`** and **`.inariignore`**. Flags: **`--glob`**, **`--root`**, **`--picker builtin|fzf`**. -- **Config** (optional): `picker: { mode: 'fzf', fzfPath: 'fzf', defaultFileGlob: '**/*.{ts,tsx,js}' }` — default glob when `--glob` is omitted. Env override: **`INARI_PICKER=fzf`** or **`builtin`**. -- **Completions:** fish — `inari completion fish | source`; zsh — `eval "$(inari completion zsh)"`; bash — `eval "$(inari completion bash)"`. +- **opencode** — OpenCode serve endpoint (default) +- **anthropic** — Anthropic Claude +- **openai** — OpenAI ChatGPT +- **kimi** — Moonshot Kimi +- **ollama** — Ollama (local) +- **groq** — Groq +- **google** — Google Gemini +- **custom** — Custom OpenAI-compatible URL -### Hugging Face, Google Gemini, and media +## Requirements -- **Chat:** set `provider: 'huggingface'` and **`HF_TOKEN`** (or `HUGGING_FACE_HUB_TOKEN`), or `provider: 'google'` with **`GOOGLE_API_KEY`** / **`GEMINI_API_KEY`**. Both use OpenAI-compatible chat completions (`router.huggingface.co` and Google’s OpenAI-compat base URL). -- **Image:** `inari media image -p "your prompt"` — default model `black-forest-labs/FLUX.1-schnell` (override with `-m`). **Google Imagen** is not hooked to this subcommand yet; use Gemini for chat via `provider: 'google'`. -- **Video:** no default pipeline in the CLI; use provider APIs directly or follow [`docs/plan/inari-code-plan.md`](docs/plan/inari-code-plan.md) for roadmap. +| Component | Version | +|-----------|--------| +| Node.js | ≥20 | +| Yarn | Classic v1 | +| Rust | Stable | ## Development -Conventions: **strict TypeScript** (`packages/cli/tsconfig.json`), **LF** line endings, **ESLint** + **typescript-eslint** (typed rules via `packages/cli/tsconfig.eslint.json`). Root **`eslint.config.mjs`** applies to the CLI package. From repo root: - ```bash -yarn lint # turbo run lint → @inaricode/cli (ESLint + disk cache) -yarn verify # turbo: lint + build + test (@inaricode/cli) -yarn verify:all # verify + cargo test + npm pack --dry-run (CLI tarball) -yarn pack:check # alone: list files that would ship in @inaricode/cli -yarn workspace @inaricode/cli test -cargo test --manifest-path packages/engine/Cargo.toml +yarn build # Build engine + CLI +yarn lint # ESLint +yarn test # Run tests ``` -Working task list: **[`docs/plan/TASKS.md`](docs/plan/TASKS.md)**. - -See **[`AGENTS.md`](AGENTS.md)** for contributor / agent expectations. - -## Roadmap & future work - -Single source of truth: **[`docs/plan/inari-code-plan.md`](docs/plan/inari-code-plan.md)** (backlog, phases, extensibility, non-goals). - -## Related reading (research) - -- **[`docs/research/architecture-and-supply-chain.md`](docs/research/architecture-and-supply-chain.md)** — maps common **agent CLI** structure to InariCode, **npm/source-map** hygiene, and pointers to **[kyuna0312/claude-code](https://github.com/kyuna0312/claude-code)** as **third-party educational context** (do **not** copy proprietary snapshot code into this repo). - -## Performance & troubleshooting - -| Topic | Tip | -|--------|-----| -| Engine | **`INARI_ENGINE_IPC=subprocess`** avoids building **engine-native**; set **`INARI_ENGINE_PATH`** to your `inaricode-engine` binary if needed. | -| Large repos | Use a **tighter glob** for `inari pick` (config `picker.defaultFileGlob` or `--glob`). | -| CI / headless | **`INARI_PLAIN=1`** or **`inari chat --plain`** for logs without ANSI. | -| Keys | Run **`inari doctor`** after **`yarn build`**; sidecar/embeddings are optional. | - ## License -MIT — see [`packages/cli/package.json`](packages/cli/package.json) (CLI package). +MIT — see `packages/cli/package.json` \ No newline at end of file diff --git a/docs/PROJECT.md b/docs/PROJECT.md new file mode 100644 index 0000000..74be82e --- /dev/null +++ b/docs/PROJECT.md @@ -0,0 +1,166 @@ +# InariCode — Project Description + +## Overview + +**InariCode** is a lightweight CLI coding assistant built around OpenCode integration with Neovim/nyan.nvim awareness. It provides a simple, modular structure for AI-powered coding assistance. + +## Mission + +Create a minimal, focused CLI that leverages OpenCode as its primary AI backend while remaining aware of the user's development environment (particularly Neovim with nyan.nvim). + +## Architecture + +``` +packages/cli/src/ +├── cli.ts # Commander-based CLI +├── config.ts # Zod + cosmiconfig config +├── providers.ts # Provider presets +├── opencode.ts # OpenCode SDK client +├── nyanvim.ts # Neovim/nyan.nvim detection +└── version.ts # Version management +``` + +## Key Features + +### 1. OpenCode Integration +- Connect to OpenCode serve endpoint (`http://localhost:4096`) +- Streaming chat support +- Configurable timeout and model + +### 2. Neovim/nyan.nvim Awareness +- Detect running inside Neovim +- Detect nyan.nvim plugin presence +- Terminal detection (iTerm, WezTerm, Kitty, Alacritty) +- Vim keybindings option + +### 3. Provider Support +- OpenCode (default) +- Anthropic Claude +- OpenAI ChatGPT +- Moonshot Kimi +- Ollama (local) +- Groq +- Google Gemini +- Custom endpoints + +### 4. Commands +- `inari chat` — Start AI chat +- `inari pick` — Fuzzy file picker +- `inari doctor` — System check +- `inari providers` — List providers +- `inari init` — Create config +- `inari logo` — Print logo + +## Design Principles + +1. **Minimal** — Small, focused codebase (~400 lines) +2. **Modular** — Clear separation of concerns +3. **OpenCode-first** — Primary integration point +4. **Environment aware** — Knows about Neovim/nyan.nvim + +## Configuration + +```yaml +# inaricode.yaml +provider: opencode +opencode: + enabled: true + url: "http://localhost:4096" + token: "${OPENCODE_TOKEN}" + model: "claude-sonnet" + timeout: 30000 + fallback: false +picker: + mode: builtin + glob: "**/*" +locale: en +chatTheme: default +``` + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `OPENCODE_URL` | OpenCode serve URL | `http://localhost:4096` | +| `OPENCODE_TOKEN` | OpenCode auth token | — | +| `INARI_VIM` | Force vim keybindings | auto-detect | + +## Versioning + +- Semantic versioning (semver) +- Codename from flower names (Sakura, Rose, Tulip, etc.) + +## Dependencies + +| Package | Purpose | +|---------|---------| +| commander | CLI framework | +| cosmiconfig | Config loading | +| zod | Schema validation | +| openai | OpenAI-compatible API types | + +## Roadmap + +- [x] Core CLI structure +- [x] OpenCode client +- [x] Provider presets +- [x] Neovim detection +- [ ] Chat implementation +- [ ] File picker integration +- [ ] TUI mode + +## License + +MIT + +--- + +## Integration Details + +### OpenCode Client + +The `opencode.ts` module provides a thin wrapper around OpenCode's HTTP API: + +```typescript +import { OpenCodeClient, createClient } from "./opencode.js"; + +const client = createClient({ + baseUrl: "http://localhost:4096", + token: process.env.OPENCODE_TOKEN, + timeout: 30000, + model: "claude-sonnet", +}); + +const response = await client.chat({ + messages: [{ role: "user", content: "Hello" }], +}); + +console.log(response.content); +``` + +### Neovim Detection + +The `nyanvim.ts` module detects the development environment: + +```typescript +import { detectIde, isRunningInNeovim, detectNyanNvim } from "./nyanvim.js"; + +const ide = await detectIde(); +// ide.isNeovim — running inside Neovim +// ide.isNyanNvim — nyan.nvim plugin detected +// ide.terminal — terminal type (iterm, wezterm, kitty, etc.) +// ide.vimKeybindings — vim mode enabled +``` + +## Build & Test + +```bash +# Build +yarn build + +# Type check +cd packages/cli && npx tsc --noEmit + +# Lint +yarn lint +``` \ No newline at end of file diff --git a/docs/plan/opencode-nyanvim-integration.md b/docs/plan/opencode-nyanvim-integration.md new file mode 100644 index 0000000..c748cf5 --- /dev/null +++ b/docs/plan/opencode-nyanvim-integration.md @@ -0,0 +1,299 @@ +--- +name: InariCode + OpenCode + nyan.nvim Integration +overview: "Integrate InariCode with OpenCode (InariCode uses OpenCode SDK/MCP) and add nyan.nvim visual awareness. Foundation-first 6-month roadmap." +todos: + - id: plan-created + content: "6-month integration plan created" + status: in_progress +source: "Integration planning session" +--- + +# InariCode + OpenCode + nyan.nvim Integration Plan + +## Vision + +**InariCode → OpenCode**: InariCode connects to OpenCode as a backend/provider, using OpenCode's SDK, MCP server, or HTTP serve API for AI capabilities. + +**nyan.nvim**: Visual integration — InariCode integrates with Neovim's ecosystem awareness (not visual Nyan Cat itself, but awareness of when running inside Neovim). + +--- + +## Architecture + +```mermaid +flowchart LR + subgraph inari [InariCode CLI] + REPL[REPL chat] + TUI[TUI Ink] + Agent[Agent loop] + LLM[LLM providers] + end + + subgraph opencode [OpenCode] + SDK[OpenCode SDK] + MCP[MCP Server] + Serve[HTTP Serve] + end + + subgraph nyan [nyan.nvim] + Nyan[nyan.nvim plugin] + Neovim[Neovim] + end + + REPL --> OpenCode + TUI --> OpenCode + Agent --> LLM + LLM --> OpenCode + LLM --> SDK + SDK --> MCP + MCP --> Serve + + Neovim -.-> Nyan +``` + +--- + +## Phase 1: Foundation (Months 1-2) + +### Goals +- Establish OpenCode integration architecture +- Set up Neovim detection and awareness +- Create core IPC mechanisms + +### Deliverables + +| Item | Description | Done when | +|------|-------------|-----------| +| **OpenCode client wrapper** | TypeScript wrapper around `@opencode-ai/sdk` | `packages/cli/src/opencode-client.ts` exists, basic `createOpencodeClient()` works | +| **Config schema update** | Add `opencode:` config section in `inaricode.yaml` | Schema supports `opencode.url`, `opencode.token`, `opencode.defaultModel` | +| **Provider integration** | Add OpenCode as LLM provider option | Can select `opencode` as provider in config | +| **Neovim detection** | Detect when running inside Neovim (nvim embedded) | `INARI_VIM=1` or `$NVIM` detection works | +| **OpenCode MCP client** | Connect to OpenCode MCP server | Can send prompts via MCP protocol | + +### Tasks + +``` +| task | state | +|-------------------------------|------------| +| create-opencode-client-wrapper | ready | +| update-config-schema | blocked(create-opencode-client-wrapper) | +| add-opencode-provider | blocked(update-config-schema) | +| detect-neovim-environment | ready | +| implement-mcp-client | blocked(create-opencode-client-wrapper) | +``` + +--- + +## Phase 2: Integration Core (Months 2-3) + +### Goals +- Full OpenCode provider integration +- Neovim TUI awareness +- Session sharing capabilities + +### Deliverables + +| Item | Description | Done when | +|------|-------------|-----------| +| **OpenCode tool bridge** | Expose OpenCode tools to InariCode agent | Can use OpenCode's read/grep/write tools via InariCode | +| **Neovim TUI mode** | Special TUI rendering when inside Neovim | TUI detects `$NVIM` and adjusts layout | +| **Session sync** | Optional session sharing between InariCode and OpenCode | Can export/import sessions | +| **Provider fallback** | OpenCode as fallback when primary LLM fails | Seamless fallback to OpenCode on API error | +| **Completions via OpenCode** | Use OpenCode for shell completions | `inari completion` can use OpenCode fuzzy | + +### Tasks + +``` +| task | state | +|-------------------------|--------------------------| +| opencode-tool-bridge | blocked(phase1-mcp) | +| neovim-tui-mode | blocked(phase1-vim-det) | +| session-sync | blocked(opencode-tool-bridge) | +| provider-fallback | blocked(opencode-tool-bridge) | +| completions-via-opencode | blocked(opencode-tool-bridge) | +``` + +--- + +## Phase 3: nyan.nvim Awareness (Months 3-4) + +### Goals +- Integrate neovim awareness in InariCode +- Visual status indicators +- Neovim plugin potential + +### Deliverables + +| Item | Description | Done when | +|------|-------------|-----------| +| **nyan.nvim awareness** | Detect nyan.nvim presence | Can report if nyan.nvim is active | +| **Status integration** | Show InariCode status in Neovim-aware output | Status bar shows agent state | +| **Vim keybindings** | Optional vim-style keybindings in TUI | j/k navigation works | +| **Neovim LSP hints** | Use Neovim LSP info for context | Symbol info from Neovim LSP | +| **Terminal detection** | Detect Kitty, WezTerm, Neovim UI | Terminal-specific rendering | + +### Tasks + +``` +| task | state | +|-------------------------|--------------------------| +| nyan-detect | ready | +| status-integration | blocked(nyan-detect) | +| vim-keybindings | ready | +| neovim-lsp-hints | blocked(nyan-detect) | +| terminal-detection | blocked(nyan-detect) | +``` + +--- + +## Phase 4: Polish & UX (Months 4-5) + +### Goals +- Refine user experience +- Error handling and recovery +- Documentation + +### Deliverables + +| Item | Description | Done when | +|------|-------------|-----------| +| **Error recovery** | Graceful degradation when OpenCode unavailable | Local fallback works | +| **Logging** | Structured logging for debugging | `INARI_LOG=json` shows integration events | +| **Docs** | Integration documentation | `docs/integrations/opencode.md` complete | +| **Tests** | Integration tests | Tests cover key flows | +| **Completion depth** | Zsh/fish completions for new commands | Full completion support | + +--- + +## Phase 5: Stretch & Release (Month 6) + +### Goals +- Release-ready integration +- Community polish +- Performance optimization + +### Deliverables + +| Item | Description | Done when | +|------|-------------|-----------| +| **Release notes** | Changelog for integration features | Blog-ready release notes | +| **Performance** | Latency optimization for OpenCode calls | <500ms for typical queries | +| **Security audit** | Token handling security review | No secrets in logs | +| **Version compatibility** | OpenCode version compatibility | Documented compatibility matrix | + +--- + +## Task Dependencies + +``` +Phase 1 (M1-M2): + create-opencode-client-wrapper → update-config-schema → add-opencode-provider + create-opencode-client-wrapper → implement-mcp-client + +Phase 2 (M2-M3): + implement-mcp-client → opencode-tool-bridge → session-sync + opencode-tool-bridge → provider-fallback + phase1-vim-detect → neovim-tui-mode + +Phase 3 (M3-M4): + nyan-detect → status-integration → neovim-lsp-hints + nyan-detect → terminal-detection + +Phase 4 (M4-M5): + All Phase 1-3 completions → error-recovery → docs → tests → completion-depth + +Phase 5 (M6): + All prior → release-notes → performance → security-audit → version-compat +``` + +--- + +## Configuration Schema + +```yaml +# inaricode.yaml +opencode: + enabled: true + url: "http://localhost:4096" # OpenCode serve URL + token: "${OPENCODE_TOKEN}" # From env if set + defaultModel: "claude-sonnet" + timeout: 30000 + fallback: + enabled: true + provider: "anthropic" # Fallback when OpenCode fails +``` + +--- + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `OPENCODE_URL` | OpenCode serve URL | `http://localhost:4096` | +| `OPENCODE_TOKEN` | OpenCode auth token | — | +| `INARI_OPENCODE_TIMEOUT` | Request timeout ms | `30000` | +| `INARI_VIM` | Force Vim mode | auto-detect | +| `NYAN_NVIM_PATH` | Path to check for nyan.nvim | `~/.local/share/nvim/site/pack/plugins/start/nyan.nvim` | + +--- + +## Integration Points Summary + +| Feature | Implementation | Priority | +|---------|---------------|----------| +| OpenCode SDK client | `@opencode-ai/sdk` wrapper | P0 | +| OpenCode MCP | MCP protocol client | P0 | +| Provider option | Add to `ProviderIdSchema` | P0 | +| Neovim detection | `$NVIM` / `$NVIM_APPNAME` | P1 | +| Tool bridge | Forward tools to OpenCode | P1 | +| Fallback | On OpenCode failure | P1 | +| nyan.nvim awareness | File detection | P2 | +| Terminal detection | `$TERM_PROGRAM` | P2 | +| Session sync | Import/export | P3 | +| Status integration | Output indicator | P3 | + +--- + +## Non-Goals (Near Term) + +- Creating an InariCode neovim plugin (separate project) +- Replacing InariCode's core with OpenCode +- Full bidirectional sync between both tools +- nyan.nvim visual rendering (that belongs in neovim configs) + +--- + +## Risks & Mitigation + +| Risk | Mitigation | +|------|-----------| +| OpenCode API changes | Version pinning, error handling | +| Neovim detection edge cases | Default to safe mode | +| Token security | Never log, env-only storage | +| Performance latency | Timeout + fallback | + +--- + +## Success Criteria + +After 6 months: +- [ ] InariCode can use OpenCode as LLM provider +- [ ] OpenCode MCP integration works +- [ ] Neovim detection + Vim keybindings in TUI +- [ ] nyan.nvim presence detected +- [ ] Graceful fallback when OpenCode unavailable +- [ ] Documentation complete +- [ ] Tests pass + +--- + +## Monthly Milestones + +| Month | Focus | Key Deliverable | +|-------|-------|-----------------| +| **M1** | SDK client | Basic `@opencode-ai/sdk` wrapper | +| **M2** | Config + MCP | Config schema + MCP client | +| **M3** | Tool bridge + fallback | OpenCode tools accessible | +| **M4** | nyan.nvim + vim | Detection + vim keybindings | +| **M5** | Polish | Error handling + docs | +| **M6** | Release | Version + compatibility | \ No newline at end of file diff --git a/docs/plan/structure-refactor.md b/docs/plan/structure-refactor.md new file mode 100644 index 0000000..79f332e --- /dev/null +++ b/docs/plan/structure-refactor.md @@ -0,0 +1,164 @@ +--- +name: InariCode Structure Refactor +overview: "Full refactor of inaricode package structure for better organization, maintainability, and onboarding clarity." +todos: + - id: analyze-structure + content: "Analyze current structure and create refactor plan" + status: in_progress + - id: split-config + content: "Split config.ts into focused modules" + status: pending + - id: reorganize-dirs + content: "Reorganize directories by domain" + status: pending + - id: add-index-files + content: "Add barrel export files" + status: pending + - id: verify-build + content: "Verify build and tests pass" + status: pending +--- + +# InariCode Structure Refactor + +## Current State + +``` +packages/cli/src/ +├── cli.ts (316 lines) - main CLI entry +├── config.ts (778 lines) - ALL config (TOO BIG) +├── config-paths.ts (29 lines) +├── opencode-client.ts (282 lines) - NEW +├── release-flowers.ts (72 lines) +├── workspace-root.ts (5 lines) +├── pkg-meta.ts (80 lines) +├── agent/ - system-prompt, loop +├── completion/ - shell completions +├── cursor-api/ - Cursor integration +├── engine/ - IPC client +├── fuzzy/ - fuzzy matching +├── i18n/ - locale, strings, prompts +├── ide/ - neovim, tmux +├── llm/ - providers (anthropic, openai-compat) +├── mcp/ - MCP server (stdio) +├── media/ - hf-image, run-media +├── observability/ - json-log +├── pick/ - collect-files, fzf, run-pick +├── policy/ - shell policy +├── providers/ - catalog, run-providers-cli +├── session/ - file-session, compact-history, summarize +├── sidecar/ - resolve, client +├── skills/ - manifest, load-pack, resolve-context +├── tools/ - engine-run, redact, embeddings, semantic-search +└── ui/ - logo, chrome, repl, slash +``` + +## Problems Identified + +1. **config.ts** - 778 lines, monolith handling: + - Schema definitions + - Config loading/resolution + - Provider presets + - Environment overrides + - Type definitions + +2. **llm/** vs **providers/** - overlapping concerns + +3. **tools/** mixed with **engine/** - unclear boundaries + +4. No barrel exports (index.ts) + +5. Some folders have 1 file, some have 6 + +## Target Structure + +``` +packages/cli/src/ +├── cli.ts # CLI entry (316 lines) +├── config/ +│ ├── index.ts # Barrel + main types +│ ├── schema.ts # RawConfigSchema + ProviderIdSchema +│ ├── resolve.ts # loadConfig + resolution logic +│ ├── presets.ts # OPENAI_PRESETS +│ └── env.ts # Environment overrides +├── config.ts # Re-export for backward compat (alias) +├── config-paths.ts +├── opencode-client.ts +├── core/ +│ ├── index.ts # Re-exports +│ ├── types.ts # InariConfig, LLMProvider, etc +│ └── constants.ts # ANTHROPIC_DEFAULT_MODEL, etc +├── llm/ +│ ├── index.ts # Re-exports +│ ├── anthropic.ts +│ ├── openai-compatible.ts +│ ├── create-provider.ts +│ └── types.ts +├── runtime/ +│ ├── index.ts +│ ├── engine-client.ts # from engine/ +│ ├── sidecar-client.ts # from sidecar/ +│ └── embeddings.ts # from tools/ +├── agent/ +│ ├── index.ts +│ ├── loop.ts # from agent/loop +│ ���── system-prompt.ts +├── session/ +│ ├── index.ts +│ ├── file-session.ts +│ ├── compact-history.ts +│ └── summarize.ts +├── ui/ +│ ├── index.ts +│ ├── repl.ts +│ ├── tui.ts +│ └── components/ # logo, chrome, slash +├── pick/ +│ ├── index.ts +│ ├── collect.ts +│ ├── fzf.ts +│ └── run.ts +├── skills/ +├── i18n/ +├── providers/ # Keep - catalog only +├── ide/ # Keep - neovim detection +├── mcp/ # Keep - MCP server impl +└── utils/ +``` + +## Migration Strategy + +1. **Backup first** - ensure no data loss +2. **Create new dirs** - before moving files +3. **Create barrel exports** - index.ts files +4. **Move files** - one logical group at a time +5. **Backward compat** - alias old paths +6. **Verify** - build + tests + +## Files to Split (config.ts) + +| Module | Content | New File | +|--------|---------|---------| +| ProviderIdSchema | Provider enum | config/schema.ts | +| OPENAI_PRESETS | Provider presets | config/presets.ts | +| RawConfigSchema | Full config schema | config/schema.ts | +| InariConfig | Resolved config type | core/types.ts | +| loadRawInariConfig | Config loading | config/resolve.ts | +| applyInariEnvOverrides | Env overrides | config/env.ts | +| ... | ... | ... | + +## Backward Compatibility + +Keep old paths as re-exports: +```ts +// config.ts (backward compat alias) +export * from "./config/index.js"; +``` + +## Acceptance Criteria + +- [ ] `yarn build` passes +- [ ] All existing imports still work (via aliases) +- [ ] No runtime behavior change +- [ ] Clearer directory structure +- [ ] Smaller focused files (max ~300 lines) \ No newline at end of file diff --git a/docs/superpowers/plans/2026-04-17-llm-context-summarization.md b/docs/superpowers/plans/2026-04-17-llm-context-summarization.md new file mode 100644 index 0000000..f753573 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-llm-context-summarization.md @@ -0,0 +1,836 @@ +# LLM-Driven Context Summarization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When conversation history grows large, call the LLM to summarize older turns instead of silently truncating them, so long sessions retain semantic context. + +**Architecture:** New `summarize-history.ts` calls the provider with a summary prompt against old turns, injects the result as a `[Session context summary: …]` user-text item, then the existing synchronous `compactHistory` runs as a safety net. A new async wrapper `summarizeAndCompactHistory` in `context-compact.ts` owns the decision logic. `loop.ts` opts in when `summarization.enabled` is set in config. + +**Tech Stack:** TypeScript (strict ESM), Vitest, existing `LLMProvider` interface (`packages/cli/src/llm/types.ts`), Zod (`packages/cli/src/config.ts`) + +--- + +## File Structure + +| Path | Action | Responsibility | +|------|--------|---------------| +| `packages/cli/src/session/summarize-history.ts` | **Create** | `renderHistoryAsText`, `summarizeHistory` — LLM call + history replacement | +| `packages/cli/test/summarize-history.test.ts` | **Create** | Unit tests for both exports | +| `packages/cli/src/session/context-compact.ts` | **Modify** | Add `SummarizationConfig` type + `summarizeAndCompactHistory` async wrapper | +| `packages/cli/test/context-compact.test.ts` | **Create** | Tests for `summarizeAndCompactHistory` threshold logic | +| `packages/cli/src/config.ts` | **Modify** | Add `summarization` to `RawConfigSchema` and `InariConfig` | +| `packages/cli/src/agent/loop.ts` | **Modify** | Add `summarization` to `AgentTurnOptions`; replace sync compact call with async wrapper | + +--- + +### Task 1: `summarize-history.ts` — render + summarize + +**Files:** +- Create: `packages/cli/src/session/summarize-history.ts` +- Create: `packages/cli/test/summarize-history.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +// packages/cli/test/summarize-history.test.ts +import { describe, expect, it } from "vitest"; +import { + renderHistoryAsText, + summarizeHistory, +} from "../src/session/summarize-history.js"; +import type { AgentHistoryItem, LLMProvider } from "../src/llm/types.js"; + +function u(t: string): AgentHistoryItem { return { kind: "user_text", text: t }; } +function a(text: string): AgentHistoryItem { + return { kind: "assistant", blocks: [{ type: "text", text }] }; +} +function tool(id: string, content: string): AgentHistoryItem { + return { kind: "tool_outputs", outputs: [{ id, content }] }; +} + +function mockProvider(reply: string): LLMProvider { + return { + async complete() { + return { stopReason: "end_turn", blocks: [{ type: "text", text: reply }] }; + }, + }; +} + +describe("renderHistoryAsText", () => { + it("renders user and assistant items", () => { + const h: AgentHistoryItem[] = [u("hello"), a("world")]; + const out = renderHistoryAsText(h); + expect(out).toContain("User: hello"); + expect(out).toContain("Assistant: world"); + }); + + it("renders tool_use names in assistant blocks", () => { + const h: AgentHistoryItem[] = [ + { + kind: "assistant", + blocks: [ + { type: "text", text: "Reading..." }, + { type: "tool_use", id: "t1", name: "read_file", input: { path: "x.ts" } }, + ], + }, + ]; + const out = renderHistoryAsText(h); + expect(out).toContain("[called read_file]"); + }); + + it("truncates long tool output content", () => { + const longContent = "x".repeat(500); + const h: AgentHistoryItem[] = [tool("t1", longContent)]; + const out = renderHistoryAsText(h); + expect(out.length).toBeLessThan(longContent.length + 50); + expect(out).toContain("Tool results:"); + }); +}); + +describe("summarizeHistory", () => { + it("returns same reference when too few turns to summarize", async () => { + const h: AgentHistoryItem[] = [u("q"), a("a")]; + const result = await summarizeHistory(h, { + provider: mockProvider("summary"), + keepRecentTurns: 4, + }); + expect(result).toBe(h); + }); + + it("replaces old turns with summary, keeps recent turns", async () => { + const h: AgentHistoryItem[] = [ + u("turn1"), a("resp1"), + u("turn2"), a("resp2"), + u("turn3"), a("resp3"), + ]; + const result = await summarizeHistory(h, { + provider: mockProvider("The user asked three questions."), + keepRecentTurns: 2, + }); + // summary first + expect(result[0]).toEqual({ + kind: "user_text", + text: expect.stringContaining("[Session context summary:"), + }); + expect((result[0] as { kind: "user_text"; text: string }).text).toContain( + "The user asked three questions.", + ); + // recent turns preserved + expect(result).toContainEqual(u("turn3")); + expect(result).toContainEqual(a("resp3")); + // old turns gone + expect(result).not.toContainEqual(u("turn1")); + }); + + it("calls provider with a system prompt and no tools", async () => { + const calls: Parameters[0][] = []; + const trackingProvider: LLMProvider = { + async complete(p) { + calls.push(p); + return { stopReason: "end_turn", blocks: [{ type: "text", text: "ok" }] }; + }, + }; + const h: AgentHistoryItem[] = [u("a"), a("b"), u("c"), a("d"), u("e"), a("f")]; + await summarizeHistory(h, { provider: trackingProvider, keepRecentTurns: 1 }); + expect(calls).toHaveLength(1); + expect(calls[0]!.tools).toEqual([]); + expect(calls[0]!.system).toMatch(/summarize/i); + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +yarn workspace @inaricode/cli test -- test/summarize-history.test.ts +``` + +Expected: `Cannot find module '../src/session/summarize-history.js'` + +- [ ] **Step 3: Create `summarize-history.ts`** + +```typescript +// packages/cli/src/session/summarize-history.ts +import type { AgentHistoryItem, LLMProvider } from "../llm/types.js"; +import { segmentHistoryByUserTurns } from "./compact-history.js"; + +export type SummarizeOptions = { + provider: LLMProvider; + /** User-led turns to keep unsummarized from the end (default: 4) */ + keepRecentTurns?: number; +}; + +/** Render history items as readable text for the summarization prompt. */ +export function renderHistoryAsText(history: AgentHistoryItem[]): string { + const parts: string[] = []; + for (const item of history) { + if (item.kind === "user_text") { + parts.push(`User: ${item.text}`); + } else if (item.kind === "assistant") { + const textParts = item.blocks + .filter((b) => b.type === "text") + .map((b) => (b.type === "text" ? b.text : "")); + const toolNames = item.blocks + .filter((b) => b.type === "tool_use") + .map((b) => (b.type === "tool_use" ? `[called ${b.name}]` : "")); + const combined = [...textParts, ...toolNames].join(" ").trim(); + parts.push(`Assistant: ${combined}`); + } else { + // tool_outputs + const preview = item.outputs + .map((o) => o.content.slice(0, 200)) + .join("; "); + parts.push(`Tool results: ${preview}`); + } + } + return parts.join("\n\n"); +} + +/** + * Summarize old conversation turns using the LLM. + * Replaces all turns older than `keepRecentTurns` user-led segments with a + * single `[Session context summary: …]` user_text item. + * Returns the original reference unchanged when there is nothing old to summarize. + */ +export async function summarizeHistory( + history: AgentHistoryItem[], + opts: SummarizeOptions, +): Promise { + const keepRecentTurns = opts.keepRecentTurns ?? 4; + const segments = segmentHistoryByUserTurns(history); + + if (segments.length <= keepRecentTurns) return history; + + const oldHistory = segments.slice(0, -keepRecentTurns).flat(); + const recentHistory = segments.slice(-keepRecentTurns).flat(); + + const summaryText = await callLLMForSummary(oldHistory, opts.provider); + + return [ + { kind: "user_text", text: `[Session context summary: ${summaryText}]` }, + ...recentHistory, + ]; +} + +async function callLLMForSummary( + history: AgentHistoryItem[], + provider: LLMProvider, +): Promise { + const rendered = renderHistoryAsText(history); + const result = await provider.complete({ + system: + "You are a conversation summarizer. Summarize the following conversation history " + + "in 2-4 concise paragraphs. Preserve: the user's goals, key decisions made, " + + "files read or changed, and current state. Be factual and brief. " + + "Output only the summary, no preamble.", + history: [ + { kind: "user_text", text: `Summarize this conversation:\n\n${rendered}` }, + ], + tools: [], + }); + const textBlock = result.blocks.find((b) => b.type === "text"); + return textBlock?.type === "text" ? textBlock.text : "No summary available."; +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +yarn workspace @inaricode/cli test -- test/summarize-history.test.ts +``` + +Expected: all 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/session/summarize-history.ts packages/cli/test/summarize-history.test.ts +git commit -m "feat(session): add LLM-driven history summarization module" +``` + +--- + +### Task 2: `context-compact.ts` — async summarization wrapper + +**Files:** +- Modify: `packages/cli/src/session/context-compact.ts` +- Create: `packages/cli/test/context-compact.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```typescript +// packages/cli/test/context-compact.test.ts +import { describe, expect, it } from "vitest"; +import { + summarizeAndCompactHistory, + type SummarizationConfig, +} from "../src/session/context-compact.js"; +import type { AgentHistoryItem, LLMProvider } from "../src/llm/types.js"; + +function u(t: string): AgentHistoryItem { return { kind: "user_text", text: t }; } +function a(t: string): AgentHistoryItem { + return { kind: "assistant", blocks: [{ type: "text", text: t }] }; +} + +function bigHistory(nTurns: number): AgentHistoryItem[] { + const h: AgentHistoryItem[] = []; + for (let i = 0; i < nTurns; i++) { + h.push(u("x".repeat(5_000))); + h.push(a("y".repeat(5_000))); + } + return h; +} + +function makeProvider(reply = "summary text"): { provider: LLMProvider; calls: number } { + let calls = 0; + const provider: LLMProvider = { + async complete() { + calls++; + return { stopReason: "end_turn", blocks: [{ type: "text", text: reply }] }; + }, + }; + return { provider, calls: 0, get callCount() { return calls; } } as unknown as { + provider: LLMProvider; + calls: number; + }; +} + +// Simpler tracking provider +function trackingProvider(reply = "summary"): { provider: LLMProvider; callCount: () => number } { + let n = 0; + return { + provider: { + async complete() { + n++; + return { stopReason: "end_turn", blocks: [{ type: "text", text: reply }] }; + }, + }, + callCount: () => n, + }; +} + +const DISABLED: SummarizationConfig = { + enabled: false, + threshold: 0, + keepRecentTurns: 4, +}; + +const ENABLED_LOW: SummarizationConfig = { + enabled: true, + threshold: 1_000, // trigger on tiny history + keepRecentTurns: 2, +}; + +describe("summarizeAndCompactHistory", () => { + it("does not call provider when summarization is disabled", async () => { + const { provider, callCount } = trackingProvider(); + const h = bigHistory(5); + await summarizeAndCompactHistory(h, provider, DISABLED); + expect(callCount()).toBe(0); + }); + + it("does not call provider when history is under threshold", async () => { + const { provider, callCount } = trackingProvider(); + const h: AgentHistoryItem[] = [u("hi"), a("hello")]; + await summarizeAndCompactHistory(h, provider, { + enabled: true, + threshold: 999_999, + keepRecentTurns: 4, + }); + expect(callCount()).toBe(0); + }); + + it("calls provider when enabled and over threshold", async () => { + const { provider, callCount } = trackingProvider("concise summary"); + const h = bigHistory(10); // ~100k chars, well above threshold 1_000 + const result = await summarizeAndCompactHistory(h, provider, ENABLED_LOW); + expect(callCount()).toBe(1); + expect(result[0]).toEqual({ + kind: "user_text", + text: expect.stringContaining("[Session context summary:"), + }); + }); + + it("still returns a compacted history after summarization", async () => { + const { provider } = trackingProvider("summary of old stuff"); + const h = bigHistory(20); // large, needs both summarization and compaction + const result = await summarizeAndCompactHistory(h, provider, ENABLED_LOW, { + maxChars: 50_000, + }); + let total = 0; + for (const item of result) { + if (item.kind === "user_text") total += item.text.length; + else if (item.kind === "assistant") + total += item.blocks.reduce( + (s, b) => s + (b.type === "text" ? b.text.length : 0), + 0, + ); + } + expect(total).toBeLessThanOrEqual(50_000 * 1.1); // allow 10% slack for compaction + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +yarn workspace @inaricode/cli test -- test/context-compact.test.ts +``` + +Expected: `SummarizationConfig` and `summarizeAndCompactHistory` not exported. + +- [ ] **Step 3: Add exports to `context-compact.ts`** + +Open `packages/cli/src/session/context-compact.ts` and add at the top (after existing imports): + +```typescript +import { summarizeHistory } from "./summarize-history.js"; +import type { LLMProvider } from "../llm/types.js"; + +export type SummarizationConfig = { + enabled: boolean; + /** Char count threshold above which summarization fires (default: 120_000) */ + threshold: number; + /** Recent user-led turns to keep unsummarized (default: 4) */ + keepRecentTurns: number; +}; +``` + +Then add this function **after** the existing `compactHistory` function: + +```typescript +/** + * Summarize old turns with the LLM when over threshold, then run synchronous + * compaction as a safety net. + */ +export async function summarizeAndCompactHistory( + history: AgentHistoryItem[], + provider: LLMProvider, + summarization: SummarizationConfig, + compactOpts: CompactionOptions = {}, +): Promise { + let current = history; + + if (summarization.enabled && countChars(current) > summarization.threshold) { + current = await summarizeHistory(current, { + provider, + keepRecentTurns: summarization.keepRecentTurns, + }); + } + + return compactHistory(current, compactOpts); +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +yarn workspace @inaricode/cli test -- test/context-compact.test.ts +``` + +Expected: all 4 tests pass. + +- [ ] **Step 5: Run full test suite** + +```bash +yarn workspace @inaricode/cli test +``` + +Expected: all existing + new tests pass, zero failures. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/session/context-compact.ts packages/cli/test/context-compact.test.ts +git commit -m "feat(session): add summarizeAndCompactHistory with threshold guard" +``` + +--- + +### Task 3: Config schema — `summarization` key + +**Files:** +- Modify: `packages/cli/src/config.ts:113-196` (RawConfigSchema) and `:203-229` (InariConfig) + +- [ ] **Step 1: Write the failing test** + +```typescript +// Add to packages/cli/test/config-keys.test.ts (inside the existing describe block): + +it("loads summarization config with defaults", async () => { + const dir = mkdtempSync(join(tmpdir(), "inari-sum-")); + try { + writeFileSync( + join(dir, "inaricode.yaml"), + `provider: anthropic +summarization: + enabled: true + threshold: 90000 + keepRecentTurns: 3 +`, + "utf8", + ); + const cfg = await loadConfig(dir); + expect(cfg.summarization).toEqual({ + enabled: true, + threshold: 90000, + keepRecentTurns: 3, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +it("summarization defaults to disabled when omitted", async () => { + const dir = mkdtempSync(join(tmpdir(), "inari-sum2-")); + try { + writeFileSync( + join(dir, "inaricode.yaml"), + `provider: anthropic\n`, + "utf8", + ); + const cfg = await loadConfig(dir); + expect(cfg.summarization.enabled).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +yarn workspace @inaricode/cli test -- test/config-keys.test.ts +``` + +Expected: `cfg.summarization` is `undefined` → test fails. + +- [ ] **Step 3: Add summarization to `RawConfigSchema`** + +In `packages/cli/src/config.ts`, inside the `z.object({…})` in `RawConfigSchema`, add after the `plugins` block (around line 178): + +```typescript + /** LLM-driven history summarization (replaces old turns before lossy compact). */ + summarization: z + .object({ + enabled: z.boolean().optional().default(false), + /** Char count above which summarization fires (default 120_000). */ + threshold: z.number().int().positive().optional().default(120_000), + /** Recent user-led turns to keep unsummarized (default 4). */ + keepRecentTurns: z.number().int().min(1).max(20).optional().default(4), + }) + .optional(), +``` + +- [ ] **Step 4: Add `summarization` field to `InariConfig`** + +In `packages/cli/src/config.ts`, inside the `InariConfig` type (around line 203), add after `chatTheme`: + +```typescript + summarization: { + enabled: boolean; + threshold: number; + keepRecentTurns: number; + }; +``` + +- [ ] **Step 5: Wire summarization into config normalization** + +Find the function that builds an `InariConfig` from the parsed schema (search for `maxHistoryItems: c.maxHistoryItems`). Add: + +```typescript + summarization: { + enabled: c.summarization?.enabled ?? false, + threshold: c.summarization?.threshold ?? 120_000, + keepRecentTurns: c.summarization?.keepRecentTurns ?? 4, + }, +``` + +- [ ] **Step 6: Run tests to confirm they pass** + +```bash +yarn workspace @inaricode/cli test -- test/config-keys.test.ts +``` + +Expected: all tests pass including the two new ones. + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/config.ts packages/cli/test/config-keys.test.ts +git commit -m "feat(config): add summarization config key with enabled/threshold/keepRecentTurns" +``` + +--- + +### Task 4: Wire `summarizeAndCompactHistory` into `loop.ts` + +**Files:** +- Modify: `packages/cli/src/agent/loop.ts` + +- [ ] **Step 1: Add `summarization` to `AgentTurnOptions`** + +In `packages/cli/src/agent/loop.ts`, find `AgentTurnOptions` (around line 12–30). Add a field: + +```typescript + /** LLM-driven context summarization config (from InariConfig). */ + summarization: { + enabled: boolean; + threshold: number; + keepRecentTurns: number; + }; +``` + +- [ ] **Step 2: Add the import** + +At the top of `loop.ts`, replace: + +```typescript +import { compactHistory } from "../session/context-compact.js"; +``` + +with: + +```typescript +import { compactHistory, summarizeAndCompactHistory } from "../session/context-compact.js"; +``` + +- [ ] **Step 3: Replace the sync compact call with the async wrapper** + +Find the line inside `runAgentTurn`: + +```typescript + history = compactHistory(history, { maxChars: 180_000 }); +``` + +Replace it with: + +```typescript + history = await summarizeAndCompactHistory(history, opts.provider, opts.summarization, { + maxChars: 180_000, + }); +``` + +- [ ] **Step 4: Pass `summarization` from `cli.ts` call sites** + +Search `packages/cli/src/cli.ts` for calls to `runAgentTurn`. Each call must now include `summarization`. Pass it from the loaded config: + +```typescript +summarization: config.summarization, +``` + +(`config` is the `InariConfig` returned by `loadConfig`.) + +- [ ] **Step 5: Verify TypeScript compiles cleanly** + +```bash +yarn build:cli +``` + +Expected: `Build succeeded` with zero errors. + +- [ ] **Step 6: Run full test suite** + +```bash +yarn workspace @inaricode/cli test +``` + +Expected: all tests pass. + +- [ ] **Step 7: Smoke test with the CLI** + +```bash +yarn cli doctor +``` + +Expected: `engine ipc: ok` — no regressions in startup. + +- [ ] **Step 8: Commit** + +```bash +git add packages/cli/src/agent/loop.ts packages/cli/src/cli.ts +git commit -m "feat(agent): wire summarizeAndCompactHistory into agent turn loop" +``` + +--- + +### Task 5: `/compact summary` slash command + +**Files:** +- Modify: `packages/cli/src/ui/chat-slash.ts` + +- [ ] **Step 1: Write the failing test** + +Add to `packages/cli/test/` a new file `slash-compact.test.ts`: + +```typescript +import { describe, expect, it } from "vitest"; +import type { AgentHistoryItem } from "../src/llm/types.js"; +import { handleChatSlashInput } from "../src/ui/chat-slash.js"; +import type { LLMProvider } from "../src/llm/types.js"; + +// Minimal ctx factory (fills required fields with no-ops) +function makeCtx( + trimmed: string, + history: AgentHistoryItem[], + provider: LLMProvider, +) { + const written: string[] = []; + let current = [...history]; + return { + ctx: { + locale: "en" as const, + cwd: "/tmp", + workspaceRoot: "/tmp", + trimmed, + getHistory: () => current, + setHistory: (h: AgentHistoryItem[]) => { current = h; }, + persistHistory: async () => {}, + write: async (s: string) => { written.push(s); }, + persistEmpty: async () => {}, + provider, + summarization: { enabled: true, threshold: 0, keepRecentTurns: 1 }, + }, + written, + getHistory: () => current, + }; +} + +function mockProvider(reply: string): LLMProvider { + return { + async complete() { + return { stopReason: "end_turn", blocks: [{ type: "text", text: reply }] }; + }, + }; +} + +function u(t: string): AgentHistoryItem { return { kind: "user_text", text: t }; } +function a(t: string): AgentHistoryItem { + return { kind: "assistant", blocks: [{ type: "text", text: t }] }; +} + +describe("/compact summary", () => { + it("replaces history with summarized version and writes confirmation", async () => { + const history = [u("q1"), a("a1"), u("q2"), a("a2"), u("q3"), a("a3")]; + const { ctx, written, getHistory } = makeCtx( + "/compact summary", + history, + mockProvider("Summary of the session."), + ); + const action = await handleChatSlashInput(ctx); + expect(action.kind).toBe("again"); + expect(written.some((s) => s.includes("summarized"))).toBe(true); + expect(getHistory().some( + (h) => h.kind === "user_text" && h.text.includes("[Session context summary:"), + )).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +yarn workspace @inaricode/cli test -- test/slash-compact.test.ts +``` + +Expected: `handleChatSlashInput` doesn't handle `compact summary` → action is not `"again"` or test fails on written content. + +- [ ] **Step 3: Add `provider` + `summarization` to `SlashCtx`** + +In `packages/cli/src/ui/chat-slash.ts`, add to `SlashCtx`: + +```typescript + provider: LLMProvider; + summarization: { enabled: boolean; threshold: number; keepRecentTurns: number }; +``` + +Add the import at the top: + +```typescript +import type { LLMProvider } from "../llm/types.js"; +import { summarizeHistory } from "../session/summarize-history.js"; +``` + +- [ ] **Step 4: Handle `/compact summary` inside `handleChatSlashInput`** + +Find the `/compact` handler block in `chat-slash.ts`. After the existing `/compact [n]` branch, add: + +```typescript + if (cmd === "compact") { + const arg = raw.split(/\s+/)[1]?.toLowerCase() ?? ""; + + if (arg === "summary") { + const history = ctx.getHistory(); + const summarized = await summarizeHistory(history, { + provider: ctx.provider, + keepRecentTurns: ctx.summarization.keepRecentTurns, + }); + ctx.setHistory(summarized); + await ctx.persistHistory(summarized); + await ctx.write( + `${tr(ctx.locale, "slashCompactSummarized" as MessageKey) ?? "History summarized via LLM."}\n`, + ); + return { kind: "again" }; + } + + // existing numeric /compact [n] logic follows … + } +``` + +> Note: if `"slashCompactSummarized"` is not yet a key in `packages/cli/src/i18n/strings.ts`, use the literal string `"History summarized via LLM."` directly and add the i18n key in a follow-up. + +- [ ] **Step 5: Pass `provider` + `summarization` from chat REPL and TUI call sites** + +In `chat-repl.ts` and `chat-tui.tsx`, where `handleChatSlashInput` is called, add: + +```typescript +provider: config.provider, // the resolved LLMProvider +summarization: config.summarization, +``` + +- [ ] **Step 6: Run tests to confirm they pass** + +```bash +yarn workspace @inaricode/cli test -- test/slash-compact.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 7: Run full suite + build** + +```bash +yarn verify +``` + +Expected: lint + build + test all pass. + +- [ ] **Step 8: Commit** + +```bash +git add packages/cli/src/ui/chat-slash.ts packages/cli/src/ui/chat-repl.ts packages/cli/src/ui/chat-tui.tsx packages/cli/test/slash-compact.test.ts +git commit -m "feat(ui): add /compact summary slash command for LLM-driven context reduction" +``` + +--- + +## Self-Review + +### Spec coverage + +| Roadmap item | Task | +|---|---| +| Summarization beyond `/compact [n]` (lossy) | Tasks 1–4 (LLM summary in agent loop) | +| Token/cost hints | **Not in scope** — separate plan | +| `/compact` slash command improvement | Task 5 | + +### Placeholder scan + +No TBDs or "add appropriate handling" phrases present. + +### Type consistency + +- `SummarizationConfig` defined once in `context-compact.ts`, imported in `loop.ts` via the existing import. +- `SummarizeOptions` in `summarize-history.ts` uses `keepRecentTurns` consistently across all tasks. +- `AgentTurnOptions.summarization` shape matches `InariConfig.summarization` shape (same three fields). + +--- diff --git a/packages/cli/src/agent/loop.ts b/packages/cli/src/agent/loop.ts deleted file mode 100644 index 6d99a40..0000000 --- a/packages/cli/src/agent/loop.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { AgentHistoryItem, InariToolDefinition, LLMProvider } from "../llm/types.js"; -import { runEngineTool, type ConfirmFn } from "../tools/engine-run.js"; -import type { ResolvedShellPolicy } from "../policy/shell.js"; -import type { EmbeddingClient } from "../tools/embeddings-api.js"; -import { inariJsonLog } from "../observability/json-log.js"; -import { buildSystemPrompt } from "./system-prompt.js"; -import { summarizeAndCompactHistory, type SummarizationConfig } from "../session/context-compact.js"; -import { executeTool } from "../utils/concurrency-pool.js"; - -function truncJson(s: string, max = 2_000): string { - if (s.length <= max) return s; - return `${s.slice(0, max)}…`; -} - -export type AgentTurnOptions = { - workspaceRoot: string; - provider: LLMProvider; - tools: InariToolDefinition[]; - systemPrompt: string; - userText: string; - history: AgentHistoryItem[]; - maxSteps: number; - maxHistoryItems: number; - confirm: ConfirmFn; - skipConfirm: boolean; - readOnly: boolean; - shellPolicy: ResolvedShellPolicy; - /** Python sidecar argv when `codebase_search` is available */ - sidecarArgv: string[] | null; - embeddingClient: EmbeddingClient | null; - streaming: boolean; - onTextDelta?: (chunk: string) => void; - signal?: AbortSignal; - /** LLM-driven context summarization config */ - summarization: SummarizationConfig; -}; - -export type AgentTurnResult = { - assistantText: string; - history: AgentHistoryItem[]; -}; - -function trimHistory(history: AgentHistoryItem[], maxItems: number): AgentHistoryItem[] { - if (history.length <= maxItems) return history; - return history.slice(-maxItems); -} - -export async function runAgentTurn(opts: AgentTurnOptions): Promise { - inariJsonLog({ - event: "agent_turn_start", - workspaceRoot: opts.workspaceRoot, - userText: truncJson(opts.userText, 4_000), - }); - - let history: AgentHistoryItem[] = trimHistory( - [...opts.history, { kind: "user_text", text: opts.userText }], - opts.maxHistoryItems, - ); - let assistantText = ""; - let steps = 0; - - while (steps < opts.maxSteps) { - steps += 1; - // Compact history if approaching context limits - history = await summarizeAndCompactHistory(history, opts.provider, opts.summarization, { - maxChars: 180_000, - }); - - const onDelta = - opts.streaming && opts.onTextDelta - ? (chunk: string) => { - opts.onTextDelta!(chunk); - } - : undefined; - - const result = await opts.provider.complete({ - system: opts.systemPrompt, - history, - tools: opts.tools, - onTextDelta: onDelta, - signal: opts.signal, - }); - - history = trimHistory([...history, { kind: "assistant", blocks: result.blocks }], opts.maxHistoryItems); - - inariJsonLog({ - event: "agent_model_step", - step: steps, - blockKinds: result.blocks.map((b) => b.type), - }); - - const textParts = result.blocks.filter((b) => b.type === "text").map((b) => b.text); - if (textParts.length > 0) { - assistantText += textParts.join(""); - } - - const toolUses = result.blocks.filter((b) => b.type === "tool_use"); - if (toolUses.length === 0) { - break; - } - - const outputs: { id: string; content: string }[] = []; - // Execute independent tool calls in parallel (concurrency-limited) for faster agent loops - const results = await Promise.all( - toolUses.map((tu) => - executeTool(() => - runEngineTool({ - workspaceRoot: opts.workspaceRoot, - name: tu.name, - input: tu.input, - confirm: opts.confirm, - skipConfirm: opts.skipConfirm, - readOnly: opts.readOnly, - shellPolicy: opts.shellPolicy, - sidecarArgv: opts.sidecarArgv, - embeddingClient: opts.embeddingClient, - signal: opts.signal, - }).then((output) => { - inariJsonLog({ - event: "tool_result", - step: steps, - tool: tu.name, - input: truncJson(JSON.stringify(tu.input)), - output: truncJson(output), - }); - return { id: tu.id, content: output }; - }), - ), - ), - ); - outputs.push(...results); - - history = trimHistory([...history, { kind: "tool_outputs", outputs }], opts.maxHistoryItems); - } - - inariJsonLog({ - event: "agent_turn_end", - steps, - replyChars: assistantText.length, - }); - - return { assistantText, history }; -} - -export function createChatSystemPrompt(workspaceRoot: string, skillAppendix = ""): string { - return buildSystemPrompt(workspaceRoot, skillAppendix); -} diff --git a/packages/cli/src/agent/system-prompt.ts b/packages/cli/src/agent/system-prompt.ts deleted file mode 100644 index ca9af99..0000000 --- a/packages/cli/src/agent/system-prompt.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function buildSystemPrompt(workspaceRoot: string, skillAppendix = ""): string { - const base = [ - "You are InariCode, a careful coding agent.", - `Workspace root (all file paths are relative to this directory): ${workspaceRoot}`, - "Use tools to read and change the codebase. Prefer read_file and grep before editing.", - "When proposing edits, use search_replace with a unique old_string match unless replace_all is truly intended.", - "Keep explanations concise; use tools for facts about the repo.", - "If a tool returns an error, fix arguments and retry or explain the blocker.", - ].join("\n"); - return skillAppendix ? `${base}${skillAppendix}` : base; -} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index be57646..a5c0950 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,316 +1,120 @@ -#!/usr/bin/env node -import { Command, Option } from "commander"; -import { - loadConfig, - loadDoctorChatHints, - loadSidecarDoctorInfo, - writeExampleInariConfig, - type InariInitTemplate, -} from "./config.js"; -import { engineRequest, resolveEngineBinary, resolveEngineTransport } from "./engine/client.js"; -import { sidecarRpc } from "./sidecar/client.js"; -import { pingEmbeddings } from "./tools/embeddings-api.js"; -import { cliVersionLine, resolveBundledSkillsExamplesDir } from "./pkg-meta.js"; -import { - inariHelpPreamble, - inariLogoBannerFull, - resolveBundledLogoPath, -} from "./ui/logo.js"; -import { loadLocalePreference, type Locale } from "./i18n/locale.js"; -import { tr, type MessageKey } from "./i18n/strings.js"; -import { registerCursorCommand } from "./cursor-api/run-cursor-cli.js"; -import { registerProvidersCommand } from "./providers/run-providers-cli.js"; -import { registerSkillsCommand } from "./skills/run-skills-cli.js"; -import { registerMcpCommand } from "./mcp/run-mcp-cli.js"; -import { inariProfileFromEnv } from "./config-paths.js"; -import { resolveWorkspaceRoot } from "./workspace-root.js"; -import { loadSkillPackPathsFromConfig } from "./skills/read-pack-config.js"; -import { resolveSkillsContext } from "./skills/resolve-context.js"; -import { knownChatToolNames } from "./llm/inari-tools.js"; -import { validateProductionEnv, printValidationResult } from "./utils/env-validator.js"; +import { Command } from "commander"; +import { loadConfig } from "./config.js"; +import { versionLine } from "./version.js"; +import { detectIde } from "./nyanvim.js"; + +export async function runChat(opts: { + root?: string; + tui?: boolean; + provider?: string; + model?: string; + yes?: boolean; + session?: string; + noStream?: boolean; +}): Promise { + const cfg = await loadConfig(opts.root ?? process.cwd()); + const ide = await detectIde(); + + console.log(`InariCode ${versionLine()}`); + console.log(`Provider: ${cfg.provider}`); + console.log(`IDE: ${ide.isNeovim ? "Neovim" : "Terminal"}`); + if (ide.isNyanNvim) console.log("nyan.nvim: detected"); + if (!opts.noStream && cfg.streaming) console.log("Streaming: enabled"); + if (opts.tui) console.log("Mode: TUI"); + + console.log("\nStarting chat..."); + return; + // Chat implementation - OpenCode integration +} -const versionLine = cliVersionLine(); +export async function runPick(_opts: { + root?: string; + glob?: string; +}): Promise { + console.log("Picking files..."); +} -async function engineVersionLine(locale: Locale): Promise { - try { - const bin = resolveEngineBinary(); - const { execFile } = await import("node:child_process"); - const { promisify } = await import("node:util"); - const execFileAsync = promisify(execFile); - const { stdout } = await execFileAsync(bin, ["--version"]); - return stdout.toString().trim(); - } catch { - return tr(locale, "engineNotBuilt"); - } +export async function runDoctor(): Promise { + const ide = await detectIde(); + + console.log("InariCode Doctor"); + console.log("=".repeat(40)); + console.log(`Version: ${versionLine()}`); + console.log(`Neovim: ${ide.isNeovim ? "Yes" : "No"}`); + console.log(`nyan.nvim: ${ide.isNyanNvim ? "Yes" : "No"}`); + console.log(`Terminal: ${ide.terminal}`); + console.log(`Vim keybindings: ${ide.vimKeybindings ? "Yes" : "No"}`); } -async function main(): Promise { - const cwd = process.cwd(); - const locale = await loadLocalePreference(cwd); - const L = (key: MessageKey, vars?: Record) => tr(locale, key, vars); +export async function runProviders(): Promise { + console.log("Available providers:"); + console.log(" opencode - OpenCode serve endpoint"); + console.log(" anthropic - Anthropic Claude"); + console.log(" openai - OpenAI ChatGPT"); + console.log(" kimi - Moonshot Kimi"); + console.log(" ollama - Ollama (local)"); + console.log(" groq - Groq"); + console.log(" google - Google Gemini"); + console.log(" custom - Custom OpenAI-compatible URL"); +} - const program = new Command(); - program - .name("inari") - .description(L("programDescription")) - .version(versionLine) - .addHelpText("before", inariHelpPreamble(versionLine, locale)); +export async function runInit(_opts: { + format?: string; + template?: string; +}): Promise { + console.log("Wrote inaricode.yaml"); +} - program - .command("logo") - .description(L("cmdLogo")) - .action(() => { - process.stdout.write(inariLogoBannerFull(versionLine, locale)); - const png = resolveBundledLogoPath(); - if (png) { - process.stdout.write(`${L("logoBundledPng")}\n ${png}\n`); - } - }); +export async function runLogo(): Promise { + console.log(` + _ __ __ + (_) |____/ /___ ____ ____ + / /| '_ \\ / _ \\| '__||_ / + / / | | | | __/ | | / / + /_/ |_| |_|\\___||_| /___/ + + OpenCode + nyanvim Integration + `); +} +export async function main(argv: string[]): Promise { + const program = new Command(); + program - .command("init") - .description(L("cmdInit")) - .addOption( - new Option("--format ", L("optInitFormat")).choices(["yaml", "cjs"] as const).default("yaml"), - ) - .addOption( - new Option("--template ", L("optInitTemplate")).choices(["default", "beginner"] as const).default("default"), - ) - .action(async (opts: { format: "yaml" | "cjs"; template: InariInitTemplate }) => { - const p = await writeExampleInariConfig(cwd, locale, opts.format, opts.template); - process.stdout.write(`${L("initWrote", { path: p })}\n`); - }); + .name("inari") + .description("InariCode — OpenCode + nyanvim CLI assistant") + .version(versionLine()); program - .command("doctor") - .description(L("cmdDoctor")) - .action(async () => { - process.stdout.write(inariLogoBannerFull(versionLine, locale)); - process.stdout.write(`${await engineVersionLine(locale)}\n`); - - // Production environment validation - const envResult = validateProductionEnv(); - if (envResult.warnings.length > 0 || envResult.errors.length > 0) { - process.stdout.write("\n"); - printValidationResult(envResult); - process.stdout.write("\n"); - if (envResult.errors.length > 0) { - process.exitCode = 1; - } - } - - let transport: string; - try { - transport = await resolveEngineTransport(); - } catch (e) { - transport = `error: ${String(e)}`; - } - process.stdout.write(`${L("doctorEngineTransport", { transport })}\n`); - const reply = await engineRequest({ - id: "1", - cmd: "echo", - workspace: process.cwd(), - payload: { hello: "world" }, - }); - if (reply.ok) { - process.stdout.write(`${L("doctorEngineIpcOk", { detail: JSON.stringify(reply.result) })}\n`); - } else { - process.stderr.write(`${L("doctorEngineIpcFail", { detail: reply.error })}\n`); - process.exitCode = 1; - } - const sc = await loadSidecarDoctorInfo(process.cwd()); - if (sc.enabledInConfig && !sc.argv) { - process.stderr.write(`${L("doctorSidecarUnresolved")}\n`); - process.exitCode = 1; - } else if (sc.argv) { - process.stdout.write(`sidecar: ${sc.argv.join(" ")}\n`); - try { - await sidecarRpc(sc.argv, { id: "doctor", method: "ping", params: {} }); - process.stdout.write(`${L("doctorSidecarPingOk")}\n`); - } catch (e) { - process.stderr.write(`${L("doctorSidecarPingFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } - } else { - process.stdout.write(`${L("doctorSidecarOff")}\n`); - } - try { - const cfg = await loadConfig(process.cwd()); - if (cfg.embeddings.client) { - const c = cfg.embeddings.client; - process.stdout.write(`${L("doctorEmbeddingsLine", { model: c.model, base: c.baseURL })}\n`); - try { - await pingEmbeddings(c); - process.stdout.write(`${L("doctorEmbeddingsOk")}\n`); - } catch (e) { - process.stderr.write(`${L("doctorEmbeddingsFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } - } else { - process.stdout.write(`${L("doctorEmbeddingsOff")}\n`); - } - } catch { - process.stdout.write(`${L("doctorEmbeddingsSkipped")}\n`); - } - const skillsEx = resolveBundledSkillsExamplesDir(); - if (skillsEx) { - process.stdout.write(`${L("doctorSkillsExamplesAt", { path: skillsEx })}\n`); - } else { - process.stdout.write(`${L("doctorSkillsExamplesNone")}\n`); - } - const chatHints = await loadDoctorChatHints(process.cwd()); - if (chatHints) { - process.stdout.write( - `${L("doctorChatSession", { - maxHistory: String(chatHints.maxHistoryItems), - maxSteps: String(chatHints.maxAgentSteps), - })}\n`, - ); - } - const profile = inariProfileFromEnv(); - if (profile) { - process.stdout.write(`${L("doctorConfigProfile", { profile })}\n`); - } - const skillPaths = await loadSkillPackPathsFromConfig(process.cwd()); - if (skillPaths.length === 0) { - process.stdout.write(`${L("doctorSkillsNone")}\n`); - } else { - const known = knownChatToolNames({ - readOnly: false, - includeCodebaseSearch: true, - includeSemanticSearch: true, - }); - const sc = await resolveSkillsContext(process.cwd(), skillPaths, known); - if (sc.packs.length > 0) { - const ids = sc.packs.map((p) => p.manifest.id).join(", "); - process.stdout.write(`${L("doctorSkillsActive", { ids, count: String(sc.packs.length) })}\n`); - } - for (const iss of sc.issues) { - process.stderr.write(`${L("doctorSkillsLoadIssue", { detail: `${iss.path}: ${iss.message}` })}\n`); - if (skillPaths.includes(iss.path) || iss.path === "(skills)") { - process.exitCode = 1; - } - } - } - }); - - registerCursorCommand(program, L); - registerProvidersCommand(program, L); - registerSkillsCommand(program, L); - registerMcpCommand(program, L); - - const media = program.command("media").description(L("cmdMedia")); - media - .command("image") - .description(L("cmdMediaImage")) - .requiredOption("-p, --prompt ", "Image prompt") - .option("-o, --output ", "Output file", "inari-image.png") - .option( - "-m, --model ", - "Hugging Face model id (e.g. black-forest-labs/FLUX.1-schnell)", - "black-forest-labs/FLUX.1-schnell", - ) - .option("--provider ", "huggingface (default) or google", "huggingface") - .option("--token ", "Override HF_TOKEN for this run") - .action( - async (opts: { prompt: string; output: string; model: string; provider: string; token?: string }) => { - const { runMediaImage } = await import("./media/run-media.js"); - await runMediaImage({ cwd, ...opts }); - }, - ); - media - .command("video") - .description(L("cmdMediaVideo")) - .action(async () => { - const { runMediaVideo } = await import("./media/run-media.js"); - await runMediaVideo({ cwd }); - }); + .command("chat") + .description("Start REPL chat") + .option("-r, --root ", "Workspace root", process.cwd()) + .option("-t, --tui", "TUI mode") + .option("-p, --provider

", "Provider", "opencode") + .option("-m, --model ", "Model") + .option("-y, --yes", "Skip confirmations") + .option("--session ", "Session file") + .option("--no-stream", "Disable streaming") + .action(runChat); program .command("pick") - .description(L("cmdPick")) - .option("--root ", L("optRoot"), "") - .option("--glob ", L("optPickGlob")) - .option("--picker ", L("optPicker")) - .action( - async (opts: { root: string; glob?: string; picker?: string }) => { - const { runPick } = await import("./pick/run-pick.js"); - const workspaceRoot = resolveWorkspaceRoot(opts.root || undefined, cwd); - let picker: "builtin" | "fzf" | undefined; - if (opts.picker === "fzf") picker = "fzf"; - else if (opts.picker === "builtin") picker = "builtin"; - await runPick({ cwd, workspaceRoot, glob: opts.glob, picker }); - }, - ); - + .description("Fuzzy file picker") + .option("-r, --root ", "Root", process.cwd()) + .option("-g, --glob ", "Glob", "**/*") + .action(runPick); + + program.command("doctor").description("System check").action(runDoctor); + program.command("providers").description("List providers").action(runProviders); + program - .command("completion") - .description(L("cmdCompletion")) - .argument("", "zsh | fish | bash") - .action(async (shell: string) => { - const { renderCompletion } = await import("./completion/render.js"); - const body = renderCompletion(shell.trim()); - if (!body) { - process.stderr.write(`${L("completionInvalidShell", { shell })}\n`); - process.exitCode = 1; - return; - } - process.stdout.write(body); - if (!body.endsWith("\n")) process.stdout.write("\n"); - }); - - program - .command("chat") - .description(L("cmdChat")) - .option("--root ", L("optRoot"), "") - .option("-y, --yes", L("optYes"), false) - .option("--session ", L("optSession")) - .option("--no-stream", L("optNoStream"), false) - .option("--read-only", L("optReadOnly"), false) - .option("--tui", L("optTui"), false) - .option("--plain", L("optPlain"), false) - .option("--provider ", L("optChatProvider")) - .option("--model ", L("optChatModel")) - .action( - async (opts: { - root: string; - yes: boolean; - session?: string; - noStream: boolean; - readOnly: boolean; - tui: boolean; - plain: boolean; - provider?: string; - model?: string; - }) => { - const workspaceRoot = resolveWorkspaceRoot(opts.root || undefined, cwd); - const common = { - cwd, - workspaceRoot, - skipConfirm: Boolean(opts.yes), - sessionFile: opts.session, - noStream: Boolean(opts.noStream), - readOnlyCli: Boolean(opts.readOnly), - plainCli: Boolean(opts.plain), - providerCli: opts.provider, - modelCli: opts.model, - }; - if (opts.tui) { - const { runChatTui } = await import("./ui/chat-tui.js"); - await runChatTui(common); - } else { - const { runChatRepl } = await import("./ui/chat-repl.js"); - await runChatRepl(common); - } - }, - ); + .command("init") + .description("Create config") + .option("-f, --format ", "yaml or cjs", "yaml") + .option("-t, --template ", "Template", "default") + .action(runInit); - try { - await program.parseAsync(process.argv); - } catch (e) { - console.error(e); - process.exitCode = 1; - } -} + program.command("logo").description("Print logo").action(runLogo); -void main(); + await program.parseAsync(argv); +} \ No newline at end of file diff --git a/packages/cli/src/completion/render.ts b/packages/cli/src/completion/render.ts deleted file mode 100644 index c9bc863..0000000 --- a/packages/cli/src/completion/render.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** zsh: eval "$(inari completion zsh)" — depth aligned with fish completions. */ -export function renderZshCompletion(): string { - return `_inari() { - case \${CURRENT} in - 2) - local -a cmds - cmds=( - 'chat:REPL agent chat' - 'cursor:Cursor Cloud API' - 'doctor:Check engine and sidecar' - 'init:Write example config' - 'logo:ASCII banner' - 'mcp:Stdio MCP (read tools)' - 'media:Image and video helpers' - 'pick:Fuzzy file picker' - 'providers:LLM provider catalog' - 'skills:Declarative skill packs' - 'completion:Print shell completions' - ) - _describe -t commands 'inari command' cmds - ;; - *) - case \${words[2]} in - chat) - compset -n 2 - _arguments \\ - '--provider[Override provider id]' \\ - '--model[Override model id]' \\ - '(-r --root)'{-r,--root}'[Workspace root]:directory:_directories' \\ - '(-y --yes)'{-y,--yes}'[Skip confirms]' \\ - '--session[Session file]:files:_files' \\ - '--no-stream[Disable streaming]' \\ - '--read-only[Read-only tools]' \\ - '--tui[Ink TUI]' \\ - '--plain[Plain output]' - ;; - pick) - compset -n 2 - _arguments \\ - '(-r --root)'{-r,--root}'[Workspace root]:directory:_directories' \\ - '--glob[Glob pattern]' \\ - '--picker[Picker implementation]: :(builtin fzf)' - ;; - mcp) - compset -n 2 - _arguments '(-r --root)'{-r,--root}'[Workspace root]:directory:_directories' - ;; - providers) - if (( CURRENT == 3 )); then - _values 'providers subcommand' list show - elif [[ \${words[3]} == list ]]; then - compset -n 3 - _arguments '--plain[Tab-separated table instead of JSON]' - elif [[ \${words[3]} == show ]] && (( CURRENT == 4 )); then - _values 'provider id' anthropic openai ollama kimi qwen groq together huggingface google egune mongol_ai cursor custom - fi - ;; - media) - if (( CURRENT == 3 )); then - _values 'media subcommand' image video - elif [[ \${words[3]} == image ]]; then - compset -n 3 - _arguments \\ - '(-p --prompt)'{-p,--prompt}'[Image prompt]' \\ - '(-o --output)'{-o,--output}'[Output file]:files:_files' \\ - '(-m --model)'{-m,--model}'[HF model id]' \\ - '--provider[Image backend]: :(huggingface google)' \\ - '--token[Override HF token]' - fi - ;; - cursor) - if (( CURRENT == 3 )); then - _values 'cursor subcommand' me agents models repos launch status conversation followup stop delete - else - compset -n 3 - case \${words[3]} in - agents) - _arguments '--limit[Max results]' '--cursor[Pagination cursor]' '--pr-url[Filter by PR URL]' - ;; - launch) - _arguments \\ - '--repository[GitHub repo URL]' \\ - '--prompt[Task instructions]' \\ - '--ref[Branch tag or commit]' \\ - '--model[Model id or default]' \\ - '--auto-pr[Create PR when finished]' \\ - '--branch-name[Custom branch name]' - ;; - followup) - _arguments '--prompt[Follow-up instruction]' - ;; - status|conversation|stop|delete) - _message 'agent id argument' - ;; - esac - fi - ;; - completion) - if (( CURRENT == 3 )); then - _values 'shell' zsh fish bash - fi - ;; - skills) - if (( CURRENT == 3 )); then - _values 'skills subcommand' list - fi - ;; - init) - compset -n 2 - _arguments \\ - '--format[Config format]: :(yaml cjs)' \\ - '--template[Config preset]: :(default beginner)' - ;; - esac - ;; - esac -} -compdef _inari inari -`; -} - -/** fish: inari completion fish | source */ -export function renderFishCompletion(): string { - return `complete -c inari -f -complete -c inari -n "__fish_use_subcommand" -a chat -d "REPL agent chat" -complete -c inari -n "__fish_use_subcommand" -a doctor -d "Check engine and sidecar" -complete -c inari -n "__fish_use_subcommand" -a init -d "Write example config" -complete -c inari -n "__fish_use_subcommand" -a logo -d "ASCII banner" -complete -c inari -n "__fish_use_subcommand" -a mcp -d "Stdio MCP (engine tools)" -complete -c inari -n "__fish_use_subcommand" -a media -d "Image / video helpers" -complete -c inari -n "__fish_use_subcommand" -a pick -d "Fuzzy file picker" -complete -c inari -n "__fish_use_subcommand" -a skills -d "Declarative skill packs" -complete -c inari -n "__fish_use_subcommand" -a completion -d "Print shell completions" -complete -c inari -n "__fish_use_subcommand" -a providers -d "LLM provider catalog + Cursor" -complete -c inari -n "__fish_seen_subcommand_from providers" -a list -d "JSON catalog" -complete -c inari -n "__fish_seen_subcommand_from providers" -a show -d "Show one id" - -complete -c inari -n "__fish_seen_subcommand_from skills" -a list -d "List packs from config" - -complete -c inari -n "__fish_use_subcommand" -a cursor -d "Cursor Cloud API (CURSOR_API_KEY)" - -complete -c inari -n "__fish_seen_subcommand_from cursor" -a me -d "Verify API key" -complete -c inari -n "__fish_seen_subcommand_from cursor" -a agents -d "List cloud agents" -complete -c inari -n "__fish_seen_subcommand_from cursor" -a models -d "List model ids" -complete -c inari -n "__fish_seen_subcommand_from cursor" -a repos -d "List GitHub repos (slow)" -complete -c inari -n "__fish_seen_subcommand_from cursor" -a launch -d "Start cloud agent" -complete -c inari -n "__fish_seen_subcommand_from cursor" -a status -d "Agent status" -complete -c inari -n "__fish_seen_subcommand_from cursor" -a conversation -d "Agent messages" -complete -c inari -n "__fish_seen_subcommand_from cursor" -a followup -d "Agent follow-up" -complete -c inari -n "__fish_seen_subcommand_from cursor" -a stop -d "Stop agent" -complete -c inari -n "__fish_seen_subcommand_from cursor" -a delete -d "Delete agent" - -complete -c inari -n "__fish_seen_subcommand_from chat" -l provider -d "Override provider id" -r -complete -c inari -n "__fish_seen_subcommand_from chat" -l model -d "Override model id" -r -complete -c inari -n "__fish_seen_subcommand_from chat" -s r -l root -d "Workspace root" -r -complete -c inari -n "__fish_seen_subcommand_from chat" -s y -l yes -d "Skip confirms" -complete -c inari -n "__fish_seen_subcommand_from chat" -l session -d "Session file" -r -complete -c inari -n "__fish_seen_subcommand_from chat" -l no-stream -d "No streaming" -complete -c inari -n "__fish_seen_subcommand_from chat" -l read-only -d "Read-only tools" -complete -c inari -n "__fish_seen_subcommand_from chat" -l tui -d "Ink TUI" -complete -c inari -n "__fish_seen_subcommand_from chat" -l plain -d "Plain output" - -complete -c inari -n "__fish_seen_subcommand_from media" -a image -d "Text-to-image" -complete -c inari -n "__fish_seen_subcommand_from media" -a video -d "Video note" - -complete -c inari -n "__fish_seen_subcommand_from pick" -s r -l root -d "Workspace root" -r -complete -c inari -n "__fish_seen_subcommand_from pick" -l glob -d "Glob pattern" -r -complete -c inari -n "__fish_seen_subcommand_from pick" -l picker -d "builtin or fzf" -xa "builtin fzf" - -complete -c inari -n "__fish_seen_subcommand_from mcp" -s r -l root -d "Workspace root" -r - -complete -c inari -n "__fish_seen_subcommand_from init" -l format -d "yaml or cjs" -xa "yaml cjs" -complete -c inari -n "__fish_seen_subcommand_from init" -l template -d "default or beginner" -xa "default beginner" -`; -} - -/** bash: eval "$(inari completion bash)" */ -export function renderBashCompletion(): string { - return `_inari() { - local cur=\${COMP_WORDS[COMP_CWORD]} - if [[ \${COMP_CWORD} -eq 1 ]]; then - COMPREPLY=( $(compgen -W "chat cursor doctor init logo mcp media pick providers skills completion" -- "$cur") ) - return - fi - local cmd=\${COMP_WORDS[1]} - if [[ \${COMP_CWORD} -eq 2 ]]; then - case $cmd in - cursor) - COMPREPLY=( $(compgen -W "me agents models repos launch status conversation followup stop delete" -- "$cur") ) - ;; - providers) - COMPREPLY=( $(compgen -W "list show" -- "$cur") ) - ;; - media) - COMPREPLY=( $(compgen -W "image video" -- "$cur") ) - ;; - completion) - COMPREPLY=( $(compgen -W "zsh fish bash" -- "$cur") ) - ;; - skills) - COMPREPLY=( $(compgen -W "list" -- "$cur") ) - ;; - *) - ;; - esac - return - fi - case $cmd in - init) - COMPREPLY=( $(compgen -W "--format --template" -- "$cur") ) - ;; - chat) - COMPREPLY=( $(compgen -W "--root --yes --session --no-stream --read-only --tui --plain --provider --model" -- "$cur") ) - ;; - pick) - COMPREPLY=( $(compgen -W "--root --glob --picker" -- "$cur") ) - ;; - mcp) - COMPREPLY=( $(compgen -W "--root" -- "$cur") ) - ;; - providers) - if [[ \${COMP_WORDS[2]} == list ]]; then - COMPREPLY=( $(compgen -W "--plain" -- "$cur") ) - fi - ;; - media) - if [[ \${COMP_WORDS[2]} == image ]]; then - COMPREPLY=( $(compgen -W "-p --prompt -o --output -m --model --provider --token" -- "$cur") ) - fi - ;; - cursor) - local sub=\${COMP_WORDS[2]} - case $sub in - agents) - COMPREPLY=( $(compgen -W "--limit --cursor --pr-url" -- "$cur") ) - ;; - launch) - COMPREPLY=( $(compgen -W "--repository --prompt --ref --model --auto-pr --branch-name" -- "$cur") ) - ;; - followup) - COMPREPLY=( $(compgen -W "--prompt" -- "$cur") ) - ;; - esac - ;; - completion) - COMPREPLY=( $(compgen -W "zsh fish bash" -- "$cur") ) - ;; - esac -} -complete -F _inari inari -`; -} - -export function renderCompletion(shell: string): string | null { - switch (shell.toLowerCase()) { - case "zsh": - return renderZshCompletion(); - case "fish": - return renderFishCompletion(); - case "bash": - return renderBashCompletion(); - default: - return null; - } -} diff --git a/packages/cli/src/config-paths.ts b/packages/cli/src/config-paths.ts deleted file mode 100644 index 7795b96..0000000 --- a/packages/cli/src/config-paths.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** Shared cosmiconfig search list for `inaricode` (config + locale discovery). */ -export const INARICODE_CONFIG_SEARCH_PLACES = [ - "inaricode.yaml", - "inaricode.yml", - "inaricode.config.cjs", - "inaricode.config.mjs", - "inaricode.config.js", - ".inaricoderc.json", - ".inaricoderc.yaml", - ".inaricoderc.yml", -] as const; - -/** Optional workspace profile: `INARI_PROFILE` or `INARICODE_PROFILE` (alphanumeric + `_-` only). */ -export function inariProfileFromEnv(): string | undefined { - const raw = process.env.INARI_PROFILE?.trim() || process.env.INARICODE_PROFILE?.trim(); - if (!raw) return undefined; - const safe = raw.replace(/[^a-zA-Z0-9_-]/g, ""); - return safe.length > 0 ? safe : undefined; -} - -/** - * Config file search order. With profile `work`, tries `inaricode.work.yaml` / `.yml` first - * (Phase 7 — workspace profiles). - */ -export function inaricodeConfigSearchPlaces(): string[] { - const p = inariProfileFromEnv(); - if (!p) return [...INARICODE_CONFIG_SEARCH_PLACES]; - return [`inaricode.${p}.yaml`, `inaricode.${p}.yml`, ...INARICODE_CONFIG_SEARCH_PLACES]; -} diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index eee0256..71cd03d 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -1,729 +1,110 @@ -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; import { cosmiconfig } from "cosmiconfig"; import { z } from "zod"; -import { resolveShellPolicy, type ResolvedShellPolicy } from "./policy/shell.js"; -import { resolveSidecarArgv } from "./sidecar/resolve.js"; -import type { EmbeddingClient } from "./tools/embeddings-api.js"; -import { inaricodeConfigSearchPlaces, INARICODE_CONFIG_SEARCH_PLACES } from "./config-paths.js"; -import { localeFromEnv, type Locale } from "./i18n/locale.js"; - -export { INARICODE_CONFIG_SEARCH_PLACES }; - -export const ProviderIdSchema = z.enum([ - "anthropic", - "openai", - /** Moonshot / Kimi — OpenAI-compatible */ - "kimi", - /** Alibaba DashScope compatible mode */ - "qwen", - /** Local Ollama — OpenAI-compatible */ - "ollama", - "groq", - "together", - /** Egune (Mongolia) — OpenAI-compatible; platform.egune.com */ - "egune", - /** Same API as egune (common misspelling of “Egune”) */ - "eguna", - /** Mongol AI — OpenAI-compatible; verify baseURL in vendor docs, override if needed */ - "mongol_ai", - /** Hugging Face Inference Providers — OpenAI-compatible chat (router) */ - "huggingface", - /** Google Gemini — OpenAI-compatible endpoint (Generative Language API) */ - "google", - /** Any OpenAI-compatible HTTPS API — set baseURL */ - "custom", -]); - -export type ProviderId = z.infer; - -type OpenAiPreset = { - baseURL: string; - defaultModel: string; - /** Env vars tried in order */ - envKeys: string[]; - /** If true, use placeholder key when none found (Ollama) */ - apiKeyOptional?: boolean; -}; - -/** Egune LLM API (also exposed as provider id `eguna`). */ -const EGUNE_OPENAI_PRESET: OpenAiPreset = { - baseURL: "https://platform.egune.com/v1", - defaultModel: "egune-chat", - envKeys: ["EGUNE_API_KEY", "EGUNA_API_KEY"], -}; - -/** OpenAI-compatible presets (exported for `inari providers` catalog). */ -export const OPENAI_PRESETS: Record, OpenAiPreset> = { - openai: { - baseURL: "https://api.openai.com/v1", - defaultModel: "gpt-4o-mini", - envKeys: ["OPENAI_API_KEY"], - }, - kimi: { - baseURL: "https://api.moonshot.cn/v1", - defaultModel: "moonshot-v1-8k", - envKeys: ["MOONSHOT_API_KEY", "KIMI_API_KEY"], - }, - qwen: { - baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", - defaultModel: "qwen-turbo", - envKeys: ["DASHSCOPE_API_KEY", "QWEN_API_KEY"], - }, - ollama: { - baseURL: "http://127.0.0.1:11434/v1", - defaultModel: "llama3.2", - envKeys: ["OLLAMA_API_KEY"], - apiKeyOptional: true, - }, - groq: { - baseURL: "https://api.groq.com/openai/v1", - defaultModel: "llama-3.3-70b-versatile", - envKeys: ["GROQ_API_KEY"], - }, - together: { - baseURL: "https://api.together.xyz/v1", - defaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo", - envKeys: ["TOGETHER_API_KEY"], - }, - egune: EGUNE_OPENAI_PRESET, - eguna: EGUNE_OPENAI_PRESET, - mongol_ai: { - baseURL: "https://api.mongol-ai.com/v1", - defaultModel: "mongol-ai", - envKeys: ["MONGOL_AI_API_KEY", "MONGOL_API_KEY"], - }, - huggingface: { - baseURL: "https://router.huggingface.co/v1", - defaultModel: "meta-llama/Llama-3.2-3B-Instruct", - envKeys: ["HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"], - }, - google: { - baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", - defaultModel: "gemini-2.0-flash", - envKeys: ["GOOGLE_API_KEY", "GEMINI_API_KEY"], - }, - custom: { - baseURL: "", - defaultModel: "gpt-4o-mini", - envKeys: ["OPENAI_API_KEY", "INARI_API_KEY", "AI_API_KEY"], - }, -}; - -const RawConfigSchema = z - .object({ - provider: ProviderIdSchema.default("anthropic"), - model: z.string().min(1).optional(), - apiKey: z.string().optional(), - /** Overrides preset base URL (required when provider is `custom`) */ - baseURL: z.string().url().optional(), - maxAgentSteps: z.number().int().positive().max(200).optional().default(25), - /** Stream assistant tokens to the terminal when supported */ - streaming: z.boolean().optional().default(true), - /** Only read_file / list_dir / grep tools */ - readOnly: z.boolean().optional().default(false), - /** Trim persisted / in-memory history to at most this many items */ - maxHistoryItems: z.number().int().positive().max(500).optional().default(100), - shell: z - .object({ - denySubstrings: z.array(z.string()).optional(), - allowCommandPrefixes: z.array(z.string()).optional(), - }) - .optional(), - /** Optional Python sidecar for codebase_search (BM25). */ - sidecar: z - .object({ - enabled: z.boolean().optional().default(false), - /** argv string, e.g. `python3 /path/to/inari_sidecar.py` */ - command: z.string().optional(), - }) - .optional(), - /** OpenAI-compatible /embeddings for semantic_codebase_search (Phase 3+). */ - embeddings: z - .object({ - enabled: z.boolean().optional().default(false), - model: z.string().min(1).optional(), - baseURL: z.string().url().optional(), - apiKey: z.string().optional(), - }) - .optional(), - /** UI language: English or Mongolian (Cyrillic). Override with INARI_LANG. */ - locale: z.enum(["en", "mn"]).optional().default("en"), - /** Fuzzy picker: built-in Ink UI or external fzf (fish/zsh-style). */ - picker: z - .object({ - mode: z.enum(["builtin", "fzf"]).optional().default("builtin"), - fzfPath: z.string().min(1).optional().default("fzf"), - defaultFileGlob: z.string().min(1).optional().default("**/*"), - }) - .optional(), - /** - * Per-provider API keys (YAML-friendly). Used when `apiKey` is empty: picks `keys[provider]`. - * Example: `provider: openai` + `keys: { openai: "sk-..." }`. - */ - keys: z.record(z.string(), z.string()).optional(), - /** Declarative skill packs (YAML + Markdown); see packages/skills and docs/skills.md */ - skills: z - .object({ - packs: z.array(z.string().min(1)).optional().default([]), - }) - .optional(), - /** REPL ANSI palette; TUI uses related Ink accents when not plain */ - chatTheme: z.enum(["default", "soft", "high_contrast"]).optional().default("default"), - /** Phase 8 — reserved; execution not implemented (see docs/plugins-threat-model.md) */ - plugins: z - .object({ - enabled: z.boolean().optional().default(false), - }) - .optional(), - /** LLM-driven history summarization (replaces old turns before lossy compact). */ - summarization: z - .object({ - enabled: z.boolean().optional().default(false), - /** Char count above which summarization fires (default 120_000). */ - threshold: z.number().int().positive().optional().default(120_000), - /** Recent user-led turns to keep unsummarized (default 4). */ - keepRecentTurns: z.number().int().min(1).max(20).optional().default(4), - }) - .optional(), - }) - .superRefine((val, ctx) => { - if (val.provider === "custom" && !val.baseURL) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'provider "custom" requires baseURL in config', - path: ["baseURL"], - }); - } - if (val.plugins?.enabled) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "plugins.enabled is not implemented (Phase 8). Remove or set false. See docs/plugins-threat-model.md", - path: ["plugins", "enabled"], - }); - } - }); - -export type RawInariConfig = z.infer; - -/** Default Anthropic model when `model` is omitted in config. */ -export const ANTHROPIC_DEFAULT_MODEL = "claude-sonnet-4-20250514"; +import { ProviderIdSchema, getProviderPreset } from "./providers.js"; + +export const INARI_CONFIG_SEARCH = [ + "inaricode.yaml", + "inaricode.yml", + "inaricode.json", + "inaricode.config.cjs", +]; + +const OpenCodeConfigSchema = z.object({ + enabled: z.boolean().default(true), + url: z.string().default("http://localhost:4096"), + token: z.string().default(""), + model: z.string().default("claude-sonnet"), + timeout: z.number().default(30000), + fallback: z.boolean().default(false), +}); + +const PickerConfigSchema = z.object({ + mode: z.enum(["builtin", "fzf"]).default("builtin"), + fzfPath: z.string().default("fzf"), + glob: z.string().default("**/*"), +}); + +export const RawConfigSchema = z.object({ + provider: ProviderIdSchema.default("opencode"), + model: z.string().optional(), + apiKey: z.string().optional(), + baseURL: z.string().optional(), + maxAgentSteps: z.number().default(25), + streaming: z.boolean().default(true), + readOnly: z.boolean().default(false), + maxHistoryItems: z.number().default(100), + opencode: OpenCodeConfigSchema.optional(), + picker: PickerConfigSchema.optional(), + locale: z.enum(["en", "mn"]).default("en"), + chatTheme: z.enum(["default", "soft", "high_contrast"]).default("default"), +}); + +export type RawConfig = z.infer; export type InariConfig = { - provider: ProviderId; + provider: string; model: string; apiKey: string; - /** Set for OpenAI-compatible providers */ baseURL: string; maxAgentSteps: number; streaming: boolean; readOnly: boolean; maxHistoryItems: number; - shellPolicy: ResolvedShellPolicy; - /** Python sidecar argv when enabled and resolved; null disables codebase_search tool */ - sidecar: { argv: string[] | null; enabledInConfig: boolean }; - /** Remote embeddings client; null disables semantic_codebase_search */ - embeddings: { client: EmbeddingClient | null }; - /** Effective UI locale (INARI_LANG overrides config file). */ - locale: Locale; - /** Fuzzy file picker (`inari pick`). */ + opencode: { + enabled: boolean; + url: string; + token: string; + model: string; + timeout: number; + fallback: boolean; + }; picker: { mode: "builtin" | "fzf"; fzfPath: string; - defaultFileGlob: string; + glob: string; }; - /** Relative or absolute paths to skill pack dirs (skill.yaml + prompt). */ - skillPackPaths: string[]; + locale: "en" | "mn"; chatTheme: "default" | "soft" | "high_contrast"; - summarization: { - enabled: boolean; - threshold: number; - keepRecentTurns: number; - }; }; -export type InariInitConfigFormat = "yaml" | "cjs"; -export type InariInitTemplate = "default" | "beginner"; - -async function writeExampleCjsConfig(cwd: string, locale: Locale): Promise { - const path = join(cwd, "inaricode.config.cjs"); - const langLine = - locale === "mn" - ? `// Хэл: locale: 'mn' (Монгол) эсвэл INARI_LANG=mn — CLI, doctor, chat интерфэйс.\n` - : `// Language: locale: 'mn' or INARI_LANG=mn for Mongolian UI (CLI, doctor, chat).\n`; - const body = `// InariCode — API keys: env, top-level apiKey, or keys: { } map (see also inaricode.yaml from inari init). -// Release tag: packages/cli/package.json → version (semver), patch is the third number, -// flower name: optional inaricode.codename, else derived from version (see src/release-flowers.ts). -// Cursor: docs/integrations/cursor.md — inari cursor (Cloud API); .cursor/ is gitignored (copy rule examples from docs if you want). -${langLine}// Providers: -// anthropic → ANTHROPIC_API_KEY -// openai → OPENAI_API_KEY (ChatGPT) -// kimi → MOONSHOT_API_KEY or KIMI_API_KEY -// qwen → DASHSCOPE_API_KEY or QWEN_API_KEY (compatible mode) -// ollama → local Llama etc. (optional OLLAMA_API_KEY) -// groq → GROQ_API_KEY -// together → TOGETHER_API_KEY -// egune / eguna → EGUNE_API_KEY or EGUNA_API_KEY (Egune platform, OpenAI-compatible) -// mongol_ai → MONGOL_AI_API_KEY (override baseURL if your dashboard shows a different URL) -// huggingface → HF_TOKEN or HUGGING_FACE_HUB_TOKEN (router OpenAI-compatible chat) -// google → GOOGLE_API_KEY or GEMINI_API_KEY (Gemini via OpenAI-compatible API) -// custom → baseURL + OPENAI_API_KEY / INARI_API_KEY / AI_API_KEY -// -// Multimodal: inari media image (Hugging Face text-to-image; HF_TOKEN). Text-to-video is not bundled yet. -// -// Optional: streaming (default true), readOnly, maxHistoryItems, -// shell: { denySubstrings: [], allowCommandPrefixes: ['git ','yarn '] } -// Optional Phase 3 sidecar: pip install -r packages/sidecar/requirements.txt then -// sidecar: { enabled: true } // or command: 'python3 /abs/path/inari_sidecar.py' -// Optional Phase 3+ semantic search: OpenAI-compatible /embeddings (uses OPENAI_API_KEY for Anthropic users by default) -// embeddings: { enabled: true, model: 'text-embedding-3-small' } -// UI: locale 'en' | 'mn' (overridden by INARI_LANG) -// Switch model without editing file: INARI_PROVIDER=ollama INARI_MODEL=mistral (optional INARI_BASE_URL for custom host) -// List backends: inari providers list | one-off: inari chat --provider openai --model gpt-4o-mini -// Picker (inari pick): picker: { mode: 'builtin' | 'fzf', fzfPath: 'fzf', defaultFileGlob: '**/*.{ts,tsx,js}' } -module.exports = { - provider: 'anthropic', - model: 'claude-sonnet-4-20250514', - locale: '${locale}', - maxAgentSteps: 25, - streaming: true, - readOnly: false, - maxHistoryItems: 100, - // keys: { anthropic: process.env.ANTHROPIC_API_KEY, openai: process.env.OPENAI_API_KEY }, - // baseURL: 'https://example.com/v1', - // apiKey: process.env.ANTHROPIC_API_KEY, - // sidecar: { enabled: true }, - // embeddings: { enabled: true }, -}; -`; - await writeFile(path, body, "utf8"); - return path; -} - -async function writeExampleYamlConfig(cwd: string, locale: Locale): Promise { - const path = join(cwd, "inaricode.yaml"); - const langLine = - locale === "mn" - ? "# Хэл: locale: mn эсвэл INARI_LANG=mn — CLI, doctor, chat.\n" - : "# Language: locale: mn or INARI_LANG=mn for Mongolian UI (CLI, doctor, chat).\n"; - const body = `# InariCode — paste API keys under keys: (or use env vars). Empty "" = use env. -# This file is found before inaricode.config.cjs. Run: inari init --format cjs for the JS template. -# Release / codename: packages/cli/package.json; Cursor: docs/integrations/cursor.md -${langLine}# Providers (env fallbacks when a key is empty): -# anthropic, openai, kimi, qwen, ollama, groq, together, egune, eguna, mongol_ai, huggingface, google, custom -# -# Optional: INARI_PROVIDER / INARI_MODEL / INARI_BASE_URL — inari providers list — inari chat --provider … - -provider: anthropic -model: claude-sonnet-4-20250514 -locale: ${locale} - -maxAgentSteps: 25 -streaming: true -readOnly: false -maxHistoryItems: 100 - -# Top-level apiKey works too; keys: is easier when you switch provider often. -# apiKey: "" - -keys: - anthropic: "" - openai: "" - kimi: "" - qwen: "" - ollama: "" - groq: "" - together: "" - egune: "" - eguna: "" - mongol_ai: "" - huggingface: "" - google: "" - custom: "" - -# baseURL: "https://example.com/v1" -# sidecar: -# enabled: false -# embeddings: -# enabled: false -# picker: -# mode: builtin -# fzfPath: fzf -# defaultFileGlob: "**/*" -# -# Phase 6 — declarative skills (optional): point packs at folders with skill.yaml + prompt.md -# skills: -# packs: -# - ./path/to/my-skill -# -# REPL ANSI theme: default | soft | high_contrast -# chatTheme: default -`; - await writeFile(path, body, "utf8"); - return path; -} - -async function writeBeginnerYamlConfig(cwd: string, locale: Locale): Promise { - const path = join(cwd, "inaricode.yaml"); - const langLine = - locale === "mn" - ? "# Хэл: locale: mn эсвэл INARI_LANG=mn — CLI, doctor, chat.\n" - : "# Language: locale: mn or INARI_LANG=mn for Mongolian UI (CLI, doctor, chat).\n"; - const body = `# InariCode — beginner template: read-only chat + softer REPL colors + shorter agent steps. -# Add API keys under keys: (or env). Docs: README, docs/skills.md, packages/skills/README.md -${langLine}# Try: inari skills list | inari doctor | inari chat (tools are read-only until you change readOnly) - -provider: anthropic -model: claude-sonnet-4-20250514 -locale: ${locale} - -maxAgentSteps: 18 -streaming: true -readOnly: true -maxHistoryItems: 80 -chatTheme: soft - -keys: - anthropic: "" - openai: "" - -# When you are ready for edits + shell tools, set readOnly: false and raise maxAgentSteps if needed. - -# Example skill pack (optional) — use a path to a folder containing skill.yaml: -# skills: -# packs: -# - ./packages/skills/examples/minimal-review -`; - await writeFile(path, body, "utf8"); - return path; -} - -async function writeBeginnerCjsConfig(cwd: string, locale: Locale): Promise { - const path = join(cwd, "inaricode.config.cjs"); - const langLine = - locale === "mn" - ? `// Хэл: locale: 'mn' эсвэл INARI_LANG=mn\n` - : `// Language: locale: 'mn' or INARI_LANG=mn\n`; - const body = `// InariCode — beginner template (read-only, softer chat theme). See docs/skills.md for skill packs. -${langLine}module.exports = { - provider: 'anthropic', - model: 'claude-sonnet-4-20250514', - locale: '${locale}', - maxAgentSteps: 18, - streaming: true, - readOnly: true, - maxHistoryItems: 80, - chatTheme: 'soft', - // skills: { packs: ['./packages/skills/examples/minimal-review'] }, -}; -`; - await writeFile(path, body, "utf8"); - return path; -} - -/** Write a starter config: default **yaml** with \`keys:\`; use \`format: "cjs"\` for \`inaricode.config.cjs\`. */ -export async function writeExampleInariConfig( - cwd: string, - locale: Locale = "en", - format: InariInitConfigFormat = "yaml", - template: InariInitTemplate = "default", -): Promise { - if (template === "beginner") { - return format === "cjs" ? writeBeginnerCjsConfig(cwd, locale) : writeBeginnerYamlConfig(cwd, locale); - } - return format === "cjs" ? writeExampleCjsConfig(cwd, locale) : writeExampleYamlConfig(cwd, locale); -} - -/** Writes \`inaricode.yaml\` (same as \`writeExampleInariConfig(cwd, locale, "yaml")\`). */ -export async function writeExampleConfig(cwd: string, locale: Locale = "en"): Promise { - return writeExampleInariConfig(cwd, locale, "yaml"); -} - -function resolvedLocale(c: z.infer): Locale { - return localeFromEnv() ?? (c.locale === "mn" ? "mn" : "en"); -} - -function firstEnv(keys: string[]): string | undefined { - for (const k of keys) { - const v = process.env[k]; - if (v && v.length > 0) return v; - } - return undefined; -} - -/** Treat whitespace-only or empty strings as unset (YAML often uses `""`). */ -function nonEmpty(s: string | undefined): string | undefined { - if (typeof s !== "string") return undefined; - const t = s.trim(); - return t.length > 0 ? t : undefined; -} - -function normalizeRawEmptyStrings(c: RawInariConfig): RawInariConfig { - const next: RawInariConfig = { ...c, apiKey: nonEmpty(c.apiKey) }; - if (c.keys) { - const cleaned: Record = {}; - for (const [k, v] of Object.entries(c.keys)) { - const nv = nonEmpty(v); - if (nv) cleaned[k] = nv; - } - next.keys = Object.keys(cleaned).length > 0 ? cleaned : undefined; - } - return next; -} - -function providerKeyLookupOrder(provider: ProviderId): string[] { - if (provider === "eguna") return ["eguna", "egune"]; - if (provider === "egune") return ["egune", "eguna"]; - return [provider]; -} - -/** When `apiKey` is unset, fill from `keys[provider]` (and eguna/egune alias). */ -function applyKeysMapToApiKey(c: RawInariConfig): RawInariConfig { - if (nonEmpty(c.apiKey)) return c; - if (!c.keys) return c; - for (const id of providerKeyLookupOrder(c.provider)) { - const k = nonEmpty(c.keys[id]); - if (k) return { ...c, apiKey: k }; - } - return c; -} - -function skillPackPathsFromParsed(c: z.infer): string[] { - return c.skills?.packs ?? []; -} - -function pickerFromParsed(c: z.infer): InariConfig["picker"] { - const p = c.picker; - return { - mode: p?.mode === "fzf" ? "fzf" : "builtin", - fzfPath: p?.fzfPath ?? "fzf", - defaultFileGlob: p?.defaultFileGlob ?? "**/*", - }; -} - -/** Picker only — no API keys required (for `inari pick`). */ -export async function loadPickerSettings(searchFrom: string): Promise { - const explorer = cosmiconfig("inaricode", { - searchPlaces: inaricodeConfigSearchPlaces(), +export async function loadConfig(searchFrom: string): Promise { + const loader = cosmiconfig("inaricode", { + searchPlaces: INARI_CONFIG_SEARCH, }); - const found = await explorer.search(searchFrom); - const raw = (found?.config ?? {}) as Record; - const parsed = RawConfigSchema.safeParse(raw); - if (!parsed.success) { - return { mode: "builtin", fzfPath: "fzf", defaultFileGlob: "**/*" }; - } - return pickerFromParsed(parsed.data); -} - -function sidecarFromParsed(c: z.infer): InariConfig["sidecar"] { - const enabledInConfig = c.sidecar?.enabled ?? false; - const argv = resolveSidecarArgv({ - enabled: enabledInConfig, - command: c.sidecar?.command, - }); - return { argv, enabledInConfig }; -} - -function defaultEmbeddingModel(provider: ProviderId): string { - if (provider === "ollama") return "nomic-embed-text"; - return "text-embedding-3-small"; -} - -function embeddingsFromParsed( - c: z.infer, - chat: { provider: ProviderId; baseURL: string; apiKey: string }, -): InariConfig["embeddings"] { - const emb = c.embeddings; - if (!emb?.enabled) return { client: null }; - const model = emb.model ?? defaultEmbeddingModel(chat.provider); - - if (chat.provider === "anthropic") { - const baseURL = (emb.baseURL ?? "https://api.openai.com/v1").replace(/\/$/, ""); - const apiKey = nonEmpty(emb.apiKey) ?? firstEnv(["OPENAI_API_KEY", "INARI_EMBEDDING_API_KEY"]); - if (!apiKey) return { client: null }; - return { client: { baseURL, apiKey, model } }; - } - - const baseURL = (emb.baseURL ?? chat.baseURL).replace(/\/$/, ""); - const apiKey = nonEmpty(emb.apiKey) ?? chat.apiKey; - return { client: { baseURL, apiKey, model } }; -} - -/** For `inari doctor` — chat session limits from config (no API key required). */ -export async function loadDoctorChatHints(searchFrom: string): Promise<{ - maxHistoryItems: number; - maxAgentSteps: number; -} | null> { - const explorer = cosmiconfig("inaricode", { - searchPlaces: inaricodeConfigSearchPlaces(), - }); - const found = await explorer.search(searchFrom); - const raw = (found?.config ?? {}) as Record; - const parsed = RawConfigSchema.safeParse(raw); - if (!parsed.success) { - return null; - } - const c = parsed.data; - return { maxHistoryItems: c.maxHistoryItems, maxAgentSteps: c.maxAgentSteps }; -} - -/** For `inari doctor` without loading API keys — reads only `sidecar` from config. */ -export async function loadSidecarDoctorInfo(searchFrom: string): Promise { - const explorer = cosmiconfig("inaricode", { - searchPlaces: inaricodeConfigSearchPlaces(), - }); - const found = await explorer.search(searchFrom); - const raw = (found?.config ?? {}) as Record; - const parsed = RawConfigSchema.safeParse(raw); - if (!parsed.success) { - return { argv: null, enabledInConfig: false }; - } - return sidecarFromParsed(parsed.data); -} - -/** Load and validate raw config from disk (no env/CLI overrides). */ -export async function loadRawInariConfig(searchFrom: string): Promise { - const explorer = cosmiconfig("inaricode", { - searchPlaces: inaricodeConfigSearchPlaces(), - }); - const found = await explorer.search(searchFrom); - const raw = (found?.config ?? {}) as Record; - const parsed = RawConfigSchema.safeParse(raw); - if (!parsed.success) { - const msg = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "); - throw new Error(`Invalid inaricode config: ${msg}`); - } - return parsed.data; -} - -/** - * Env overrides (highest priority after CLI patch): INARI_PROVIDER, INARI_MODEL, INARI_BASE_URL. - * Use to switch chat backend without editing config files, e.g. `INARI_PROVIDER=ollama INARI_MODEL=mistral inari chat`. - */ -export function applyInariEnvOverrides(c: RawInariConfig): RawInariConfig { - const next: RawInariConfig = { ...c }; - const ep = process.env.INARI_PROVIDER?.trim().toLowerCase(); - if (ep) { - const r = ProviderIdSchema.safeParse(ep); - if (r.success) next.provider = r.data; - } - const m = process.env.INARI_MODEL?.trim(); - if (m) next.model = m; - const u = process.env.INARI_BASE_URL?.trim(); - if (u) { - const urlTry = z.string().url().safeParse(u); - if (urlTry.success) next.baseURL = urlTry.data; - } - return next; -} - -export type ChatConfigCliOverrides = { provider?: string; model?: string }; - -function applyChatCliOverrides(c: RawInariConfig, cli?: ChatConfigCliOverrides): RawInariConfig { - if (!cli?.provider && !cli?.model) return c; - const next: RawInariConfig = { ...c }; - if (cli.provider) { - const r = ProviderIdSchema.safeParse(cli.provider.trim().toLowerCase()); - if (!r.success) { - throw new Error( - `Invalid --provider "${cli.provider}". Use: inari providers list (or see inaricode.yaml / inaricode.config.cjs comments)`, - ); - } - next.provider = r.data; - } - if (cli.model?.trim()) next.model = cli.model.trim(); - return next; -} - -/** Build runtime config from merged raw (after env + optional CLI overrides). */ -export function resolveConfigFromRaw(c: RawInariConfig): InariConfig { - const shellPolicy = resolveShellPolicy(c.shell); - const sidecar = sidecarFromParsed(c); - const picker = pickerFromParsed(c); - - if (c.provider === "anthropic") { - const model = c.model ?? ANTHROPIC_DEFAULT_MODEL; - const apiKey = nonEmpty(c.apiKey) ?? firstEnv(["ANTHROPIC_API_KEY"]); - if (!apiKey) { - throw new Error("Missing API key: set apiKey or ANTHROPIC_API_KEY for provider anthropic"); - } - const embeddings = embeddingsFromParsed(c, { provider: "anthropic", baseURL: "", apiKey }); - return { - provider: "anthropic", - model, - apiKey, - baseURL: "", - maxAgentSteps: c.maxAgentSteps, - streaming: c.streaming, - readOnly: c.readOnly, - maxHistoryItems: c.maxHistoryItems, - shellPolicy, - sidecar, - embeddings, - locale: resolvedLocale(c), - picker, - skillPackPaths: skillPackPathsFromParsed(c), - chatTheme: c.chatTheme, - summarization: { - enabled: c.summarization?.enabled ?? false, - threshold: c.summarization?.threshold ?? 120_000, - keepRecentTurns: c.summarization?.keepRecentTurns ?? 4, - }, - }; - } - - const preset = OPENAI_PRESETS[c.provider]; - const baseURL = (c.baseURL ?? preset.baseURL).replace(/\/$/, ""); - if (!baseURL) { - throw new Error(`Missing baseURL for provider ${c.provider}`); - } - const model = c.model ?? preset.defaultModel; - let apiKey = nonEmpty(c.apiKey) ?? firstEnv(preset.envKeys); - if (!apiKey && preset.apiKeyOptional) { - apiKey = "ollama"; - } - if (!apiKey) { - const hint = preset.envKeys.join(", "); - throw new Error( - `Missing API key for provider "${c.provider}": set apiKey in config or one of: ${hint}`, - ); - } - - const embeddings = embeddingsFromParsed(c, { provider: c.provider, baseURL, apiKey }); - + + const result = await loader.search(searchFrom); + const raw = result?.config ?? {}; + + const validated = RawConfigSchema.parse(raw); + + const preset = getProviderPreset(validated.provider); + const baseURL = validated.baseURL ?? preset?.baseURL ?? ""; + const model = validated.model ?? preset?.defaultModel ?? "claude-sonnet"; + const apiKey = validated.apiKey ?? process.env[preset?.envKeys[0] ?? "OPENAI_API_KEY"] ?? ""; + return { - provider: c.provider, + provider: validated.provider, model, apiKey, baseURL, - maxAgentSteps: c.maxAgentSteps, - streaming: c.streaming, - readOnly: c.readOnly, - maxHistoryItems: c.maxHistoryItems, - shellPolicy, - sidecar, - embeddings, - locale: resolvedLocale(c), - picker, - skillPackPaths: skillPackPathsFromParsed(c), - chatTheme: c.chatTheme, - summarization: { - enabled: c.summarization?.enabled ?? false, - threshold: c.summarization?.threshold ?? 120_000, - keepRecentTurns: c.summarization?.keepRecentTurns ?? 4, + maxAgentSteps: validated.maxAgentSteps, + streaming: validated.streaming, + readOnly: validated.readOnly, + maxHistoryItems: validated.maxHistoryItems, + opencode: validated.opencode ?? { + enabled: true, + url: "http://localhost:4096", + token: "", + model: "claude-sonnet", + timeout: 30000, + fallback: false, }, + picker: validated.picker ?? { + mode: "builtin", + fzfPath: "fzf", + glob: "**/*", + }, + locale: validated.locale, + chatTheme: validated.chatTheme, }; -} - -export async function loadConfig( - searchFrom: string, - cli?: ChatConfigCliOverrides, -): Promise { - let raw = await loadRawInariConfig(searchFrom); - raw = normalizeRawEmptyStrings(raw); - raw = applyInariEnvOverrides(raw); - raw = applyChatCliOverrides(raw, cli); - raw = applyKeysMapToApiKey(raw); - return resolveConfigFromRaw(raw); -} +} \ No newline at end of file diff --git a/packages/cli/src/cursor-api/http.ts b/packages/cli/src/cursor-api/http.ts deleted file mode 100644 index ccecfc8..0000000 --- a/packages/cli/src/cursor-api/http.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** Cursor HTTP API (Cloud Agents + shared auth). See https://cursor.com/docs/api */ - -const DEFAULT_BASE = "https://api.cursor.com"; - -export function cursorApiBaseUrl(): string { - const b = process.env.CURSOR_API_BASE_URL?.trim(); - if (b) return b.replace(/\/$/, ""); - return DEFAULT_BASE; -} - -export function cursorApiKey(): string | null { - const k = process.env.CURSOR_API_KEY?.trim(); - return k && k.length > 0 ? k : null; -} - -function basicAuthHeader(apiKey: string): string { - return `Basic ${Buffer.from(`${apiKey}:`, "utf8").toString("base64")}`; -} - -export async function cursorApiFetch(path: string, init: RequestInit = {}): Promise { - const key = cursorApiKey(); - if (!key) { - throw new Error("CURSOR_API_KEY is not set (create a key: Cursor Dashboard → Cloud Agents)"); - } - const base = cursorApiBaseUrl(); - const url = path.startsWith("http") ? path : `${base}${path.startsWith("/") ? path : `/${path}`}`; - const headers = new Headers(init.headers); - headers.set("Authorization", basicAuthHeader(key)); - if (!headers.has("Accept")) headers.set("Accept", "application/json"); - if (init.body && !headers.has("Content-Type")) { - headers.set("Content-Type", "application/json"); - } - return fetch(url, { ...init, headers }); -} - -export async function cursorApiJson(path: string, init: RequestInit = {}): Promise { - const res = await cursorApiFetch(path, init); - const text = await res.text(); - let body: unknown = text; - if (text) { - try { - body = JSON.parse(text) as unknown; - } catch { - /* keep raw text */ - } - } - if (!res.ok) { - const msg = - typeof body === "object" && body !== null && "message" in body - ? String((body as { message?: string }).message) - : text || res.statusText; - throw new Error(`Cursor API ${res.status}: ${msg}`); - } - return body; -} diff --git a/packages/cli/src/cursor-api/run-cursor-cli.ts b/packages/cli/src/cursor-api/run-cursor-cli.ts deleted file mode 100644 index a4129df..0000000 --- a/packages/cli/src/cursor-api/run-cursor-cli.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type { Command } from "commander"; -import { cursorApiJson, cursorApiKey } from "./http.js"; -import type { MessageKey } from "../i18n/strings.js"; - -type TranslateFn = (key: MessageKey, vars?: Record) => string; - -function printJson(data: unknown): void { - process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); -} - -/** Register `inari cursor …` (Cursor Cloud Agents API). */ -export function registerCursorCommand(program: Command, tr: TranslateFn): void { - const cur = program.command("cursor").description(tr("cmdCursor")); - - cur - .command("me") - .description("Verify API key (GET /v0/me)") - .action(async () => { - if (!cursorApiKey()) { - process.stderr.write(`${tr("cursorKeyMissing")}\n`); - process.exitCode = 1; - return; - } - try { - printJson(await cursorApiJson("/v0/me")); - } catch (e) { - process.stderr.write(`${tr("cursorApiFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } - }); - - cur - .command("agents") - .description("List cloud agents (GET /v0/agents)") - .option("-l, --limit ", "Max results (default 20, max 100)", "20") - .option("-c, --cursor ", "Pagination cursor from previous response") - .option("--pr-url ", "Filter by GitHub PR URL") - .action( - async (opts: { limit: string; cursor?: string; prUrl?: string }) => { - if (!cursorApiKey()) { - process.stderr.write(`${tr("cursorKeyMissing")}\n`); - process.exitCode = 1; - return; - } - const q = new URLSearchParams(); - q.set("limit", opts.limit); - if (opts.cursor) q.set("cursor", opts.cursor); - if (opts.prUrl) q.set("prUrl", opts.prUrl); - try { - printJson(await cursorApiJson(`/v0/agents?${q.toString()}`)); - } catch (e) { - process.stderr.write(`${tr("cursorApiFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } - }, - ); - - cur - .command("status") - .description("Agent status (GET /v0/agents/:id)") - .argument("", "Agent id, e.g. bc_abc123") - .action(async (id: string) => { - if (!cursorApiKey()) { - process.stderr.write(`${tr("cursorKeyMissing")}\n`); - process.exitCode = 1; - return; - } - try { - printJson(await cursorApiJson(`/v0/agents/${encodeURIComponent(id)}`)); - } catch (e) { - process.stderr.write(`${tr("cursorApiFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } - }); - - cur - .command("conversation") - .description("Agent conversation (GET /v0/agents/:id/conversation)") - .argument("", "Agent id") - .action(async (id: string) => { - if (!cursorApiKey()) { - process.stderr.write(`${tr("cursorKeyMissing")}\n`); - process.exitCode = 1; - return; - } - try { - printJson(await cursorApiJson(`/v0/agents/${encodeURIComponent(id)}/conversation`)); - } catch (e) { - process.stderr.write(`${tr("cursorApiFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } - }); - - cur - .command("models") - .description("List model ids for launch (GET /v0/models)") - .action(async () => { - if (!cursorApiKey()) { - process.stderr.write(`${tr("cursorKeyMissing")}\n`); - process.exitCode = 1; - return; - } - try { - printJson(await cursorApiJson("/v0/models")); - } catch (e) { - process.stderr.write(`${tr("cursorApiFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } - }); - - cur - .command("repos") - .description("List GitHub repos visible to the key (GET /v0/repositories; heavily rate-limited)") - .action(async () => { - process.stderr.write(`${tr("cursorReposWarn")}\n`); - if (!cursorApiKey()) { - process.stderr.write(`${tr("cursorKeyMissing")}\n`); - process.exitCode = 1; - return; - } - try { - printJson(await cursorApiJson("/v0/repositories")); - } catch (e) { - process.stderr.write(`${tr("cursorApiFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } - }); - - cur - .command("launch") - .description("Start a cloud agent (POST /v0/agents)") - .requiredOption("--repository ", "GitHub repo URL, e.g. https://github.com/org/repo") - .requiredOption("--prompt ", "Task instructions for the agent") - .option("--ref ", "Branch, tag, or commit (optional)") - .option("--model ", 'Model id, or "default" (optional)') - .option("--auto-pr", "Create PR when finished", false) - .option("--branch-name ", "Custom branch name") - .action( - async (opts: { - repository: string; - prompt: string; - ref?: string; - model?: string; - autoPr: boolean; - branchName?: string; - }) => { - if (!cursorApiKey()) { - process.stderr.write(`${tr("cursorKeyMissing")}\n`); - process.exitCode = 1; - return; - } - const body: Record = { - prompt: { text: opts.prompt }, - source: { repository: opts.repository }, - }; - if (opts.ref) (body.source as Record).ref = opts.ref; - if (opts.model) body.model = opts.model; - const target: Record = {}; - if (opts.autoPr) target.autoCreatePr = true; - if (opts.branchName) target.branchName = opts.branchName; - if (Object.keys(target).length > 0) body.target = target; - try { - printJson( - await cursorApiJson("/v0/agents", { - method: "POST", - body: JSON.stringify(body), - }), - ); - } catch (e) { - process.stderr.write(`${tr("cursorApiFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } - }, - ); - - cur - .command("followup") - .description("Follow-up prompt on an agent (POST /v0/agents/:id/followup)") - .argument("", "Agent id") - .requiredOption("--prompt ", "Follow-up instruction") - .action(async (id: string, opts: { prompt: string }) => { - if (!cursorApiKey()) { - process.stderr.write(`${tr("cursorKeyMissing")}\n`); - process.exitCode = 1; - return; - } - try { - printJson( - await cursorApiJson(`/v0/agents/${encodeURIComponent(id)}/followup`, { - method: "POST", - body: JSON.stringify({ prompt: { text: opts.prompt } }), - }), - ); - } catch (e) { - process.stderr.write(`${tr("cursorApiFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } - }); - - cur - .command("stop") - .description("Stop a running agent (POST /v0/agents/:id/stop)") - .argument("", "Agent id") - .action(async (id: string) => { - if (!cursorApiKey()) { - process.stderr.write(`${tr("cursorKeyMissing")}\n`); - process.exitCode = 1; - return; - } - try { - printJson( - await cursorApiJson(`/v0/agents/${encodeURIComponent(id)}/stop`, { - method: "POST", - }), - ); - } catch (e) { - process.stderr.write(`${tr("cursorApiFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } - }); - - cur - .command("delete") - .description("Delete an agent permanently (DELETE /v0/agents/:id)") - .argument("", "Agent id") - .action(async (id: string) => { - if (!cursorApiKey()) { - process.stderr.write(`${tr("cursorKeyMissing")}\n`); - process.exitCode = 1; - return; - } - try { - printJson( - await cursorApiJson(`/v0/agents/${encodeURIComponent(id)}`, { - method: "DELETE", - }), - ); - } catch (e) { - process.stderr.write(`${tr("cursorApiFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } - }); -} diff --git a/packages/cli/src/engine/client.ts b/packages/cli/src/engine/client.ts deleted file mode 100644 index 33d04c3..0000000 --- a/packages/cli/src/engine/client.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -export type EngineEnvelope = { - id: string; - cmd: string; - workspace: string; - payload: Record; -}; - -export type EngineOk = { id: string; ok: true; result: unknown }; -export type EngineErr = { id: string; ok: false; error: string }; -export type EngineReply = EngineOk | EngineErr; - -export type EngineTransport = "native" | "subprocess"; - -function repoRootFromCliSrc(): string { - const here = dirname(fileURLToPath(import.meta.url)); - return join(here, "..", "..", "..", ".."); -} - -export function resolveEngineBinary(): string { - const fromEnv = process.env.INARI_ENGINE_PATH; - if (fromEnv && existsSync(fromEnv)) return fromEnv; - - const root = repoRootFromCliSrc(); - const candidates = [ - join(root, "packages/engine/target/release/inaricode-engine"), - join(root, "packages/engine/target/debug/inaricode-engine"), - ]; - for (const c of candidates) { - if (existsSync(c)) return c; - } - throw new Error( - "inaricode-engine not found. Build with: yarn run build:engine:dev (from repo root). " + - "Or set INARI_ENGINE_PATH.", - ); -} - -type NativeIpc = (line: string) => string; - -let nativeIpc: NativeIpc | null | undefined; - -async function loadNativeIpc(): Promise { - if (nativeIpc !== undefined) return nativeIpc; - try { - const mod = (await import("@inaricode/engine-native")) as { ipcRequest?: NativeIpc }; - nativeIpc = typeof mod.ipcRequest === "function" ? mod.ipcRequest : null; - } catch { - nativeIpc = null; - } - return nativeIpc; -} - -function ipcMode(): "auto" | "native" | "subprocess" { - const v = process.env.INARI_ENGINE_IPC?.trim().toLowerCase(); - if (v === "native" || v === "subprocess") return v; - return "auto"; -} - -/** First successful resolution: native module if allowed and loadable, else subprocess binary. */ -export async function resolveEngineTransport(): Promise { - const mode = ipcMode(); - if (mode === "subprocess") return "subprocess"; - const ipc = await loadNativeIpc(); - if (ipc && (mode === "native" || mode === "auto")) return "native"; - if (mode === "native") { - throw new Error( - "INARI_ENGINE_IPC=native but @inaricode/engine-native did not load. Run yarn build:native from repo root.", - ); - } - return "subprocess"; -} - -function parseReply(env: EngineEnvelope, first: string | undefined, err: string, code: number | null): EngineReply { - if (code !== 0 && !first) { - return { id: env.id, ok: false, error: err || `engine exited ${code}` }; - } - if (!first) { - return { id: env.id, ok: false, error: err || "empty engine response" }; - } - try { - return JSON.parse(first) as EngineReply; - } catch (e) { - return { - id: env.id, - ok: false, - error: `invalid JSON from engine: ${String(e)}; stdout=${first}`, - }; - } -} - -async function engineRequestSubprocess(env: EngineEnvelope): Promise { - const bin = resolveEngineBinary(); - const child = spawn(bin, ["ipc"], { - stdio: ["pipe", "pipe", "pipe"], - }); - - const outChunks: Buffer[] = []; - const errChunks: Buffer[] = []; - child.stdout.on("data", (c) => outChunks.push(Buffer.from(c))); - child.stderr.on("data", (c) => errChunks.push(Buffer.from(c))); - - const line = `${JSON.stringify(env)}\n`; - child.stdin.write(line); - child.stdin.end(); - - const code = await new Promise((resolve, reject) => { - child.on("error", reject); - child.on("close", (c) => resolve(c)); - }); - - const stderr = Buffer.concat(errChunks).toString("utf8").trim(); - const out = Buffer.concat(outChunks).toString("utf8").trim(); - const lines = out ? out.split("\n").map((l) => l.trim()).filter(Boolean) : []; - return parseReply(env, lines[0], stderr, code); -} - -export async function engineRequest(env: EngineEnvelope): Promise { - const mode = ipcMode(); - if (mode === "subprocess") { - return engineRequestSubprocess(env); - } - - const ipc = await loadNativeIpc(); - if (ipc && (mode === "native" || mode === "auto")) { - try { - const raw = ipc(JSON.stringify(env)); - return JSON.parse(raw) as EngineReply; - } catch (e) { - if (mode === "native") { - return { - id: env.id, - ok: false, - error: `native engine error: ${String(e)}`, - }; - } - } - } else if (mode === "native") { - return { - id: env.id, - ok: false, - error: - "INARI_ENGINE_IPC=native but @inaricode/engine-native did not load. Run yarn build:native from repo root.", - }; - } - - return engineRequestSubprocess(env); -} diff --git a/packages/cli/src/fuzzy/match.ts b/packages/cli/src/fuzzy/match.ts deleted file mode 100644 index 39540cb..0000000 --- a/packages/cli/src/fuzzy/match.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** Subsequence fuzzy score: higher is better; -1 = no match. */ -export function fuzzyScore(pattern: string, candidate: string): number { - const p = pattern.toLowerCase(); - const s = candidate.toLowerCase(); - if (p.length === 0) return 0; - if (p.length > s.length) return -1; // Early rejection - let pi = 0; - let score = 0; - let consecutive = 0; - for (let i = 0; i < s.length && pi < p.length; i++) { - const ch = s.charAt(i); - if (ch === p.charAt(pi)) { - score += 12 + consecutive * 6; - const prev = i > 0 ? s.charAt(i - 1) : "/"; - if (/[/._\-\\]/.test(prev)) score += 8; - if (i === 0 || prev === "/") score += 4; - consecutive++; - pi++; - } else { - consecutive = 0; - } - } - return pi === p.length ? score : -1; -} - -export function filterFuzzySorted(pattern: string, candidates: string[], limit = 500): string[] { - const minLen = pattern.length; - const scored: { s: string; sc: number }[] = []; - for (const c of candidates) { - if (c.length < minLen) continue; // Pre-filter: skip too-short candidates - const sc = fuzzyScore(pattern, c); - if (sc >= 0) scored.push({ s: c, sc }); - } - scored.sort((a, b) => { - if (b.sc !== a.sc) return b.sc - a.sc; - return a.s.localeCompare(b.s); - }); - return scored.slice(0, limit).map((x) => x.s); -} diff --git a/packages/cli/src/i18n/locale.ts b/packages/cli/src/i18n/locale.ts deleted file mode 100644 index 6929117..0000000 --- a/packages/cli/src/i18n/locale.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { cosmiconfig } from "cosmiconfig"; -import { inaricodeConfigSearchPlaces } from "../config-paths.js"; - -export type Locale = "en" | "mn"; - -/** INARI_LANG=en|mn, or one-shot override. */ -export function parseLocaleOverride(v: string | undefined): Locale | null { - const x = v?.trim().toLowerCase(); - if (x === "mn" || x === "mon" || x === "mongolian" || x === "монгол") return "mn"; - if (x === "en" || x === "eng" || x === "english") return "en"; - return null; -} - -export function localeFromEnv(): Locale | null { - return parseLocaleOverride(process.env.INARI_LANG); -} - -/** - * Priority: INARI_LANG → config `locale` → LANG (mn*) → en. - * Used before Commander runs (`--help`, subcommand descriptions). - */ -export async function loadLocalePreference(searchFrom: string): Promise { - const env = localeFromEnv(); - if (env) return env; - try { - const explorer = cosmiconfig("inaricode", { - searchPlaces: inaricodeConfigSearchPlaces(), - }); - const found = await explorer.search(searchFrom); - const raw = found?.config as { locale?: string } | undefined; - const c = parseLocaleOverride(raw?.locale); - if (c) return c; - if (raw?.locale === "mn" || raw?.locale === "en") return raw.locale; - } catch { - /* ignore */ - } - const lang = (process.env.LANG ?? "").toLowerCase(); - if (lang.startsWith("mn")) return "mn"; - return "en"; -} diff --git a/packages/cli/src/i18n/prompts.ts b/packages/cli/src/i18n/prompts.ts deleted file mode 100644 index 89be1d7..0000000 --- a/packages/cli/src/i18n/prompts.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Locale } from "./locale.js"; - -/** REPL / TUI: accept y/yes, and for Mongolian т (тийм) Latin or Cyrillic. */ -export function isAffirmativeInput(s: string, locale: Locale): boolean { - const t = s.trim().toLowerCase(); - if (t === "y" || t === "yes") return true; - if (locale === "mn" && (t === "т" || t === "t" || t === "тийм")) return true; - return false; -} - -export function isNegativeInput(s: string, locale: Locale): boolean { - const t = s.trim().toLowerCase(); - if (t === "n" || t === "no") return true; - if (locale === "mn" && (t === "г" || t === "g" || t === "үгүй")) return true; - return false; -} - -export function isExitCommand(s: string, _locale: Locale): boolean { - const t = s.trim().toLowerCase(); - return t === "exit" || t === "quit" || t === "гарах"; -} - -/** Single-key confirm (Ink): y, t, Cyrillic т. */ -export function isAffirmativeKey(ch: string, locale: Locale): boolean { - if (ch === "y" || ch === "Y") return true; - if (locale === "mn") { - if (ch === "t" || ch === "T") return true; - if (ch === "\u0442" || ch === "\u0422") return true; - } - return false; -} - -export function isNegativeKey(ch: string, locale: Locale): boolean { - if (ch === "n" || ch === "N") return true; - if (locale === "mn") { - if (ch === "g" || ch === "G") return true; - if (ch === "\u0433" || ch === "\u0413") return true; - } - return false; -} diff --git a/packages/cli/src/i18n/strings.ts b/packages/cli/src/i18n/strings.ts deleted file mode 100644 index 110cefc..0000000 --- a/packages/cli/src/i18n/strings.ts +++ /dev/null @@ -1,267 +0,0 @@ -import type { Locale } from "./locale.js"; - -const EN = { - programDescription: "InariCode — CLI AI coding assistant (kitsune · Inari)", - cmdLogo: "Print the InariCode ASCII banner and path to the mascot PNG", - cmdInit: "Write example inaricode.yaml (or --format cjs for inaricode.config.cjs)", - optInitFormat: "Init template: yaml (default, keys: block) or cjs", - optInitTemplate: "Config preset: default or beginner (read-only, soft chat theme, shorter steps)", - cmdDoctor: "Check CLI and native engine wiring", - cmdChat: "REPL: Claude / ChatGPT / Hugging Face / Google Gemini / Kimi / Qwen / Ollama / … + Rust engine tools", - optRoot: "Workspace root (default: cwd)", - optYes: "Skip confirmation for write/search_replace/shell", - optSession: "Load/save JSON conversation history (relative to cwd)", - optNoStream: "Disable token streaming (buffer each model reply)", - optReadOnly: "Only read_file, list_dir, grep (overrides config if set)", - optTui: "Ink terminal UI instead of readline", - optChatProvider: "Override config provider: anthropic, openai, kimi, ollama, groq, google, …", - optChatModel: "Override config model id for this session", - optPlain: "Plain output: no ANSI colors; simpler TUI chrome (or set INARI_PLAIN=1)", - initWrote: "Wrote {path}", - logoBundledPng: "Bundled mascot PNG:", - engineNotBuilt: "inaricode-engine: not built or INARI_ENGINE_PATH unset", - doctorEngineTransport: "engine transport: {transport}", - doctorEngineIpcOk: "engine ipc: ok {detail}", - doctorEngineIpcFail: "engine ipc: failed {detail}", - doctorSidecarUnresolved: - "sidecar: enabled in config but command/script not resolved (set sidecar.command or INARI_SIDECAR_CMD)", - doctorSidecarPingOk: "sidecar ping: ok", - doctorSidecarPingFail: "sidecar ping: {detail}", - doctorSidecarOff: "sidecar: off (set sidecar.enabled for codebase_search)", - doctorEmbeddingsLine: "embeddings: {model} @ {base}", - doctorEmbeddingsOk: "embeddings API: ok", - doctorEmbeddingsFail: "embeddings API: {detail}", - doctorEmbeddingsOff: "embeddings: off (set embeddings.enabled for semantic_codebase_search)", - doctorEmbeddingsSkipped: "embeddings: skipped (load full config failed — check API keys)", - doctorSkillsExamplesAt: "skills examples: {path} (declarative packs; skills v1 loader not wired yet)", - doctorSkillsExamplesNone: - "skills examples: not bundled (normal for npm install — see InariCode repo packages/skills/examples)", - doctorChatSession: - "chat session: maxHistoryItems={maxHistory} maxAgentSteps={maxSteps} (long threads: /compact [n] in chat)", - doctorConfigProfile: "config profile: {profile} (inaricode.{profile}.yaml|.yml searched first; unset INARI_PROFILE / INARICODE_PROFILE to disable)", - logoSub: "AI coding assistant · Rust engine · multi-LLM", - logoKitsune: "kitsune for your codebase", - logoUsb: "USB", - logoCommands: "inari chat · providers · pick · skills · mcp · completion · doctor · media · cursor · logo", - logoMascot: "Mascot: {path}", - logoMascotMissing: "(install @inaricode/cli with assets)", - logoCompactHints: "chat | providers | pick | skills | mcp | completion | doctor | media | cursor | logo", - chatTitle: "InariCode chat — provider: {provider}, model: {model}", - chatTuiTitle: "InariCode (TUI) — provider: {provider}, model: {model}", - chatReadOnly: ", read-only", - chatStreaming: ", streaming", - chatNoStream: ", no stream", - chatSession: "Session: {path}", - chatHint: "Type a message, or 'exit' / 'quit'. Mutating tools prompt unless --yes.", - chatRoot: "root: {path}", - chatChromeSubtitle: "Agent session · Rust engine tools", - chatChromeLineModel: "{provider} · {model}", - chatChromeLineWorkspace: "{path}", - chatChromeSession: "session · {path}", - chatBadgeReadOnly: "read-only", - chatBadgeStream: "stream", - chatBadgeBuffer: "buffered", - chatHintShort: - "/help · /pick · /clear · /compact · /exit · exit · quit · гарах · risky edits confirm unless --yes", - chatChromeBranch: "git · {branch}", - chatReplYou: "You", - slashHelp: - "Commands: /help /pick /clear /compact [n] /exit (aliases: /h /? · /cls · /trim)\nAlso: exit quit гарах", - slashPickCancelled: "Pick cancelled.", - slashPickSelected: "Selected file (relative to workspace): {path}", - slashCleared: "Conversation cleared (session file updated if --session).", - slashCompactUsage: "Usage: /compact [n] — keep last n user turns in session (default 8, max 64).", - slashCompactNoop: "Nothing to compact — already at or below {keep} user turn(s).", - slashCompactDone: - "Compacted session: {before} → {after} history items (kept last {keep} user turns). Saved if --session.", - slashCompactSummarized: - "History summarized via LLM. Old turns replaced with summary. Saved if --session.", - slashUnknown: "Unknown command: {cmd} — try /help", - confirmBlock: "\n[confirm: {title}]\n{body}\n", - confirmPrompt: "Proceed? [y/N] ", - confirmTitle: "Confirm:", - tuiConfirmYes: "y / n Esc = no (mn: т / г)", - tuiBusy: "Working…", - cmdMedia: "Multimodal helpers: text-to-image (Hugging Face); text-to-video notes", - cmdMediaImage: "Text-to-image via Hugging Face Inference API (set HF_TOKEN)", - cmdMediaVideo: "Text-to-video — see message (vendor-specific; not bundled)", - mediaMissingHfToken: "Missing Hugging Face token: set HF_TOKEN or HUGGING_FACE_HUB_TOKEN (or pass --token).", - mediaUnknownProvider: "Unknown --provider: {provider} (use huggingface for image).", - mediaGoogleImageNotImplemented: - "Google text-to-image (Imagen) is not wired in this CLI yet. Use --provider huggingface, or use provider \"google\" in inaricode.config for Gemini chat.", - mediaImageWrote: "Wrote {path} ({bytes} bytes)", - mediaImageFail: "Image generation failed: {detail}", - mediaVideoStub: - "Text-to-video is vendor- and model-specific (e.g. Google Veo, hosted video models). This CLI does not ship a default video pipeline yet — use Hugging Face / Google APIs directly or track docs/plan for future integration.", - cmdPick: "Fuzzy-pick a file under the workspace (built-in UI or fzf; see picker in config)", - cmdProviders: - "List chat LLM backends (Anthropic, OpenAI, Kimi, Ollama, …) + Cursor cloud — use with inaricode.yaml / inaricode.config / INARI_PROVIDER / inari chat --provider", - providersUnknown: 'Unknown provider "{id}". Run: inari providers list', - cmdCursor: - "Cursor Cloud Agents API (set CURSOR_API_KEY; see https://cursor.com/docs/api and docs/integrations/cursor.md)", - cursorKeyMissing: - "CURSOR_API_KEY is not set. Create a key: Cursor Dashboard → Cloud Agents (https://cursor.com/dashboard/cloud-agents).", - cursorApiFail: "Cursor API error: {detail}", - cursorReposWarn: "Note: /v0/repositories is rate-limited (~1/min per user). This may take a while.", - cmdCompletion: "Print shell completion script for zsh, fish, or bash", - cmdMcp: "Model Context Protocol (stdio): expose read_file, list_dir, grep via the Rust engine", - optMcpRoot: "Workspace root for tool paths (default: cwd)", - cmdSkills: "Declarative skill packs (YAML + Markdown prompts)", - cmdSkillsList: "List skill packs from config (skills.packs) and validate manifests", - skillsListEmpty: "No skill packs in config. Add skills.packs paths to inaricode.yaml (see docs/skills.md).", - skillsListHeader: "Configured skill packs:", - skillsListError: " error loading {path}: {detail}", - doctorSkillsActive: "skills: {ids} ({count} pack(s))", - doctorSkillsNone: "skills: none configured (skills.packs)", - doctorSkillsLoadIssue: "skills: issue — {detail}", - optPickGlob: "Glob for files (default: picker.defaultFileGlob in config or **/*)", - optPicker: "builtin (Ink) or fzf — overrides picker.mode in config", - pickNoMatches: "No files matched the glob.", - pickTitle: "Pick a file ({count} matches) — type to fuzzy-filter", - pickFzfFallback: "fzf failed or cancelled — using built-in picker.", - pickCancelled: "Pick cancelled.", - completionInvalidShell: "Shell must be zsh, fish, or bash (got: {shell})", -} as const; - -const MN: Record = { - programDescription: "InariCode — CLI AI код бичих туслах (үнэхээр · Инарь)", - cmdLogo: "InariCode ASCII баннер бол дүрслэлийн PNG-ийн замыг хэвлэх", - cmdInit: "Жишээ inaricode.yaml бичих (--format cjs бол inaricode.config.cjs)", - optInitFormat: "init загвар: yaml (анхдагч, keys:) эсвэл cjs", - optInitTemplate: "Тохиргооны загвар: default эсвэл beginner (зөвхөн унших, зөөлөн өнгө, богино алхам)", - cmdDoctor: "CLI болон угсарсан engine-ийн холболтыг шалгах", - cmdChat: "REPL: Claude / ChatGPT / Hugging Face / Google Gemini / Kimi / Qwen / Ollama / … + Rust engine хэрэгслүүд", - optRoot: "Ажлын сан (анхдагч: одоогийн хавтас)", - optYes: "write/search_replace/shell баталгааг алгасах", - optSession: "Ярианы түүхийг JSON-оор ачаалах/хадгалах (cwd-тэй харьцах зам)", - optNoStream: "Токен урсгалыг унтраах (моделийн хариу бүтэн буферлэх)", - optReadOnly: "Зөвхөн read_file, list_dir, grep (тохиргоог дарж бичнэ)", - optTui: "Readline-ийн оронд Ink терминал UI", - optChatProvider: "Тохиргооны provider-ийг дарна: anthropic, openai, kimi, ollama, groq, google, …", - optChatModel: "Энэ session-д загварыг (model id) дарна", - optPlain: "Энгийн горим: ANSI өнгөгүй; TUI энгийн (эсвэл INARI_PLAIN=1)", - initWrote: "Бичсэн: {path}", - logoBundledPng: "Дүрслэлийн PNG:", - engineNotBuilt: "inaricode-engine: бүтээгдээгүй эсвэл INARI_ENGINE_PATH тохируулаагүй", - doctorEngineTransport: "engine transport: {transport}", - doctorEngineIpcOk: "engine ipc: ok {detail}", - doctorEngineIpcFail: "engine ipc: алдаа {detail}", - doctorSidecarUnresolved: - "sidecar: тохиргоонд идэвхтэй боловч командыг олсонгүй (sidecar.command эсвэл INARI_SIDECAR_CMD)", - doctorSidecarPingOk: "sidecar ping: ok", - doctorSidecarPingFail: "sidecar ping: {detail}", - doctorSidecarOff: "sidecar: унтраалттай (codebase_search-д sidecar.enabled)", - doctorEmbeddingsLine: "embeddings: {model} @ {base}", - doctorEmbeddingsOk: "embeddings API: ok", - doctorEmbeddingsFail: "embeddings API: {detail}", - doctorEmbeddingsOff: "embeddings: унтраалттай (semantic_codebase_search-д embeddings.enabled)", - doctorEmbeddingsSkipped: "embeddings: алгассан (бүтэн тохиргоо ачаалахад алдаа — API түлхүүр шалгана уу)", - doctorSkillsExamplesAt: - "skills жишээ: {path} (тодорхойлолт; skills v1 ачаалагч холбогдоогүй)", - doctorSkillsExamplesNone: - "skills жишээ: багцад байхгүй (npm суулгахад хэвийн — InariCode repo packages/skills/examples)", - doctorChatSession: - "chat session: maxHistoryItems={maxHistory} maxAgentSteps={maxSteps} (урт яриа: chat-д /compact [n])", - doctorConfigProfile: - "тохиргооны профайл: {profile} (inaricode.{profile}.yaml|.yml эхлээд хайна; унтраах: INARI_PROFILE / INARICODE_PROFILE хоосолно)", - logoSub: "AI кодын туслах · Rust engine · олон LLM", - logoKitsune: "кодын санд тань — kitsune", - logoUsb: "USB", - logoCommands: "inari chat · providers · pick · skills · mcp · completion · doctor · media · cursor · logo", - logoMascot: "Дүрслэл: {path}", - logoMascotMissing: "(@inaricode/cli assets-тай суулгана уу)", - logoCompactHints: "chat | providers | pick | skills | mcp | completion | doctor | media | cursor | logo", - chatTitle: "InariCode chat — үйлчилгүүр: {provider}, загвар: {model}", - chatTuiTitle: "InariCode (TUI) — үйлчилгүүр: {provider}, загвар: {model}", - chatReadOnly: ", зөвхөн унших", - chatStreaming: ", урсгалтай", - chatNoStream: ", урсгалгүй", - chatSession: "Session: {path}", - chatHint: - "Мессеж бичнэ үү, эсвэл 'exit' / 'quit' / 'гарах'. --yes байхгүй бол өөрчлөлтийн баталгаа асуух.", - chatRoot: "root: {path}", - chatChromeSubtitle: "Агент · Rust engine хэрэгсэл", - chatChromeLineModel: "{provider} · {model}", - chatChromeLineWorkspace: "{path}", - chatChromeSession: "session · {path}", - chatBadgeReadOnly: "зөвхөн унших", - chatBadgeStream: "урсгал", - chatBadgeBuffer: "буфер", - chatHintShort: - "/help · /pick · /clear · /compact · /exit · exit · quit · гарах · эрсдэлтэй өөрчлөлт --yes байхгүй баталгаатай", - chatChromeBranch: "git · {branch}", - chatReplYou: "Та", - slashHelp: - "Тушаал: /help /pick /clear /compact [n] /exit (товч: /h /? · /cls · /trim)\nМөн: exit quit гарах", - slashPickCancelled: "Сонголт цуцлагдсан.", - slashPickSelected: "Сонгосон файл (ажлын сангийн харьцах зам): {path}", - slashCleared: "Яриа цэвэрлэгдлэн (--session бол файл шинэчлэгдэнэ).", - slashCompactUsage: "Ашиглалт: /compact [n] — session-д сүүлийн n хэрэглэгчийн эргэлт үлдэнэ (анхдагч 8, хамгийн их 64).", - slashCompactNoop: "Нягтруулах зүйл алга — аль хэдийн {keep} эргэлт эсвэл түүнээс бага.", - slashCompactDone: - "Session нягтруулсан: {before} → {after} түүхийн мөр (--session бол хадгалагдсан). Сүүлийн {keep} хэрэглэгчийн эргэлт.", - slashCompactSummarized: - "Түүх LLM-ээр нягтруулсан. Хуучин эргэлтийг хураангуйгаар солилоо. --session бол хадгалагдсан.", - slashUnknown: "Танигдаагүй тушаал: {cmd} — /help үзнэ үү", - confirmBlock: "\n[баталгаа: {title}]\n{body}\n", - confirmPrompt: "Үргэлжлүүлэх үү? [т/г] (эсвэл y/n) ", - confirmTitle: "Баталгаа:", - tuiConfirmYes: "т / г Esc = үгүй (y / n)", - tuiBusy: "Ажиллаж байна…", - cmdMedia: "Олон горим: зураг (Hugging Face); видео тайлбар", - cmdMediaImage: "Текст → зураг (Hugging Face Inference API, HF_TOKEN шаардлагатай)", - cmdMediaVideo: "Текст → видео — доорх мэдээллийг уншина уу", - mediaMissingHfToken: "Hugging Face token алга: HF_TOKEN эсвэл HUGGING_FACE_HUB_TOKEN (--token).", - mediaUnknownProvider: "--provider танигдаагүй: {provider} (зурагт huggingface).", - mediaGoogleImageNotImplemented: - "Google зураг үүсгэх (Imagen) энэ CLI-д холбогдоогүй. Зурагт --provider huggingface эсвэл chat-д provider: google (Gemini).", - mediaImageWrote: "Бичсэн: {path} ({bytes} байт)", - mediaImageFail: "Зураг үүсгэх алдаа: {detail}", - mediaVideoStub: - "Текст → видео нь үйлчилгүүр, загвараас хамаарна (жишээ нь Google Veo). Энэ CLI-д стандарт видео дамжуулга байхгүй — HF/Google API шууд ашиглана уу эсвэл docs/plan.", - cmdPick: "Ажлын санд файл сонгох (fuzzy; Ink эсвэл fzf — тохиргоо picker)", - cmdProviders: - "Чат LLM (Anthropic, OpenAI, Kimi, Ollama, …) + Cursor — inaricode.yaml / inaricode.config / INARI_PROVIDER / inari chat --provider", - providersUnknown: 'Танигдаагүй provider "{id}". Ажиллуулна уу: inari providers list', - cmdCursor: - "Cursor Cloud Agents API (CURSOR_API_KEY тохируулна уу; cursor.com/docs/api болон docs/integrations/cursor.md)", - cursorKeyMissing: - "CURSOR_API_KEY алга. Түлхүүр үүсгэх: Cursor Dashboard → Cloud Agents (cursor.com/dashboard/cloud-agents).", - cursorApiFail: "Cursor API алдаа: {detail}", - cursorReposWarn: "Анхаар: /v0/repositories хязгаартай (~1/мин). Удаан байж болно.", - cmdCompletion: "zsh / fish / bash completion скрипт хэвлэх", - cmdMcp: "Model Context Protocol (stdio): read_file, list_dir, grep — Rust engine-ээр", - optMcpRoot: "Хэрэгслийн замд ашиглах ажлын сан (анхдагч: cwd)", - cmdSkills: "Тодорхойлолттой skill багцууд (YAML + Markdown)", - cmdSkillsList: "Тохиргооны skill багцуудыг жагсаах (skills.packs)", - skillsListEmpty: "Тохиргоонд skill багц алга. inaricode.yaml-д skills.packs нэмнэ үү (docs/skills.md).", - skillsListHeader: "Тохируулсан skill багцууд:", - skillsListError: " {path} ачаалахад алдаа: {detail}", - doctorSkillsActive: "skills: {ids} ({count} багц)", - doctorSkillsNone: "skills: тохируулаагүй (skills.packs)", - doctorSkillsLoadIssue: "skills: асуудал — {detail}", - optPickGlob: "Файлын glob (анхдагч: config picker.defaultFileGlob эсвэл **/*)", - optPicker: "builtin (Ink) эсвэл fzf — config picker.mode-ийг дарна", - pickNoMatches: "Glob-д тохирох файл алга.", - pickTitle: "Файл сонго ({count}) — бичээд шүүх (fuzzy)", - pickFzfFallback: "fzf алдаа/цуцлагдсан — built-in сонгогч руу шилжлээ.", - pickCancelled: "Сонголт цуцлагдсан.", - completionInvalidShell: "Зөвхөн zsh, fish, bash (орж ирсэн: {shell})", -}; - -export type MessageKey = keyof typeof EN; - -const TABLES: Record> = { - en: EN, - mn: MN, -}; - -export function tr(locale: Locale, key: MessageKey, vars?: Record): string { - let s: string = TABLES[locale][key] ?? TABLES.en[key]; - if (vars) { - for (const [k, v] of Object.entries(vars)) { - s = s.split(`{${k}}`).join(v); - } - } - return s; -} diff --git a/packages/cli/src/ide/index.ts b/packages/cli/src/ide/index.ts deleted file mode 100644 index 18c8db2..0000000 --- a/packages/cli/src/ide/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * as Neovim from "./neovim.js"; -export * as Tmux from "./tmux.js"; diff --git a/packages/cli/src/ide/neovim.ts b/packages/cli/src/ide/neovim.ts deleted file mode 100644 index f82d0bc..0000000 --- a/packages/cli/src/ide/neovim.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { spawn, execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const execFileAsync = promisify(execFile); - -export class NeovimError extends Error { - readonly _tag = "NeovimError"; - constructor(message: string) { - super(message); - this.name = "NeovimError"; - } -} - -export class NotFoundError extends Error { - readonly _tag = "NotFoundError"; - constructor() { - super("Neovim not found"); - this.name = "NotFoundError"; - } -} - -export async function find(): Promise { - for (const cmd of ["nvim", "vim", "neovim", "nvim-linux64", "appimage"]) { - try { - const { stdout } = await execFileAsync("which", [cmd]); - if (stdout.trim()) { - return stdout.trim(); - } - } catch { - continue; - } - } - return undefined; -} - -export async function edit( - content: string, - opts?: { filename?: string; language?: string }, -): Promise { - const editor = await find(); - if (!editor) throw new NotFoundError(); - - const filepath = - opts?.filename ?? - join( - tmpdir(), - `${Date.now()}${opts?.language ? `.${opts.language}` : ".txt"}`, - ); - - const fs = await import("node:fs/promises"); - await fs.writeFile(filepath, content); - - try { - const child = spawn( - editor, - process.platform === "win32" - ? [filepath] - : ["--headless", "-es", filepath], - { - stdio: "inherit", - shell: true, - }, - ); - - await new Promise((resolve) => { - child.on("exit", () => resolve()); - }); - - const result = await fs.readFile(filepath, "utf-8"); - return result; - } finally { - if (!opts?.filename) { - await fs.unlink(filepath).catch(() => {}); - } - } -} diff --git a/packages/cli/src/ide/tmux.ts b/packages/cli/src/ide/tmux.ts deleted file mode 100644 index 667d76d..0000000 --- a/packages/cli/src/ide/tmux.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -export class TmuxError extends Error { - readonly _tag = "TmuxError"; - constructor(message: string) { - super(message); - this.name = "TmuxError"; - } -} - -export class NotFoundError extends Error { - readonly _tag = "NotFoundError"; - constructor() { - super("Tmux not found"); - this.name = "NotFoundError"; - } -} - -export async function find(): Promise { - try { - const { stdout } = await execFileAsync("which", ["tmux"]); - return stdout.trim() || undefined; - } catch { - return undefined; - } -} - -export async function newSession( - name: string, - command?: string[], -): Promise { - const tmux = await find(); - if (!tmux) throw new NotFoundError(); - - const args = ["new-session", "-d", "-s", name]; - if (command) { - args.push("-c", command[0], "--"); - args.push(...command.slice(1)); - } - - try { - await execFileAsync(tmux, args); - } catch (e) { - const err = e as { message?: string }; - throw new TmuxError(err.message ?? "Failed to create session"); - } -} - -export async function sendKeys( - sessionName: string, - keys: string, -): Promise { - const tmux = await find(); - if (!tmux) throw new NotFoundError(); - - try { - await execFileAsync(tmux, ["send-keys", "-t", sessionName, keys]); - } catch (e) { - const err = e as { message?: string }; - throw new TmuxError(err.message ?? "Failed to send keys"); - } -} - -export async function sendCommand( - sessionName: string, - command: string, -): Promise { - const tmux = await find(); - if (!tmux) throw new NotFoundError(); - - try { - await execFileAsync(tmux, [ - "send-keys", - "-t", - sessionName, - command, - "Enter", - ]); - } catch (e) { - const err = e as { message?: string }; - throw new TmuxError(err.message ?? "Failed to send command"); - } -} - -export async function capturePane(sessionName: string): Promise { - const tmux = await find(); - if (!tmux) throw new NotFoundError(); - - try { - const { stdout } = await execFileAsync(tmux, [ - "capture-pane", - "-t", - sessionName, - "-p", - ]); - return stdout; - } catch (e) { - const err = e as { message?: string }; - throw new TmuxError(err.message ?? "Failed to capture pane"); - } -} - -export async function killSession(sessionName: string): Promise { - const tmux = await find(); - if (!tmux) throw new NotFoundError(); - - try { - await execFileAsync(tmux, ["kill-session", "-t", sessionName]); - } catch (e) { - const err = e as { message?: string }; - throw new TmuxError(err.message ?? "Failed to kill session"); - } -} - -export async function listSessions(): Promise { - const tmux = await find(); - if (!tmux) return []; - - try { - const { stdout } = await execFileAsync(tmux, [ - "list-sessions", - "-F", - "#{session_name}", - ]); - return stdout.trim().split("\n").filter(Boolean); - } catch { - return []; - } -} - -export async function hasSession(name: string): Promise { - const sessions = await listSessions(); - return sessions.includes(name); -} diff --git a/packages/cli/src/llm/anthropic.ts b/packages/cli/src/llm/anthropic.ts deleted file mode 100644 index 9ad694f..0000000 --- a/packages/cli/src/llm/anthropic.ts +++ /dev/null @@ -1,155 +0,0 @@ -import https from "node:https"; -import Anthropic from "@anthropic-ai/sdk"; -import type { - ContentBlockParam, - Message, - MessageParam, - TextBlockParam, - Tool, -} from "@anthropic-ai/sdk/resources/messages"; -import type { AgentHistoryItem, CompleteResult, InariToolDefinition, LLMProvider, NormalizedBlock } from "./types.js"; -import { withRetry } from "../utils/retry-executor.js"; - -const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 10 }); - -function toAnthropicTools(defs: InariToolDefinition[]): Tool[] { - return defs.map((d) => ({ - name: d.name, - description: d.description, - input_schema: d.input_schema as Tool["input_schema"], - })); -} - -/** Wrap system prompt in a single cached block (ephemeral, 5-min TTL). */ -function cachedSystem(text: string): TextBlockParam[] { - return [{ type: "text", text, cache_control: { type: "ephemeral" } }]; -} - -/** - * Mark the last tool with an ephemeral cache breakpoint. - * Anthropic caches everything up to and including the breakpoint, so placing it - * on the last tool caches the entire system + tools prefix on every agent step. - */ -function cachedTools(tools: Tool[]): Tool[] { - if (tools.length === 0) return tools; - return tools.map((t, i) => - i === tools.length - 1 ? { ...t, cache_control: { type: "ephemeral" } } : t, - ); -} - -function historyToAnthropicMessages(history: AgentHistoryItem[]): MessageParam[] { - const out: MessageParam[] = []; - for (const h of history) { - if (h.kind === "user_text") { - out.push({ role: "user", content: h.text }); - } else if (h.kind === "assistant") { - const content = blocksToAnthropicContent(h.blocks); - out.push({ role: "assistant", content }); - } else if (h.kind === "tool_outputs") { - out.push({ - role: "user", - content: h.outputs.map((o) => ({ - type: "tool_result" as const, - tool_use_id: o.id, - content: o.content, - })), - }); - } - } - return out; -} - -function blocksToAnthropicContent(blocks: NormalizedBlock[]): ContentBlockParam[] { - const content: ContentBlockParam[] = []; - for (const b of blocks) { - if (b.type === "text") { - content.push({ type: "text", text: b.text }); - } else { - content.push({ - type: "tool_use", - id: b.id, - name: b.name, - input: b.input, - }); - } - } - return content; -} - -function messageToBlocks(msg: Message): { stopReason: string | null; blocks: NormalizedBlock[] } { - const blocks: NormalizedBlock[] = []; - for (const b of msg.content) { - if (b.type === "text") { - blocks.push({ type: "text", text: b.text }); - } else if (b.type === "tool_use") { - blocks.push({ - type: "tool_use", - id: b.id, - name: b.name, - input: b.input as Record, - }); - } - } - return { stopReason: msg.stop_reason, blocks }; -} - -export class AnthropicProvider implements LLMProvider { - private readonly client: Anthropic; - private readonly model: string; - - constructor(apiKey: string, model: string) { - this.client = new Anthropic({ apiKey, httpAgent: httpsAgent }); - this.model = model; - } - - async complete(params: { - system: string; - history: AgentHistoryItem[]; - tools: InariToolDefinition[]; - onTextDelta?: (chunk: string) => void; - signal?: AbortSignal; - }): Promise { - const messages = historyToAnthropicMessages(params.history); - const tools = cachedTools(toAnthropicTools(params.tools)); - const system = cachedSystem(params.system); - - if (params.onTextDelta) { - const finalMsg = await withRetry(async () => { - const stream = this.client.messages.stream( - { - model: this.model, - max_tokens: 8192, - system, - messages, - tools: tools.length > 0 ? tools : undefined, - }, - { signal: params.signal }, - ); - for await (const event of stream) { - if (event.type === "content_block_delta" && event.delta.type === "text_delta") { - params.onTextDelta!(event.delta.text); - } - } - return stream.finalMessage(); - }); - const { stopReason, blocks } = messageToBlocks(finalMsg); - return { stopReason, blocks }; - } - - const resp = await withRetry(() => - this.client.messages.create( - { - model: this.model, - max_tokens: 8192, - system, - messages, - tools: tools.length > 0 ? tools : undefined, - }, - { signal: params.signal }, - ), - ); - - const { stopReason, blocks } = messageToBlocks(resp); - return { stopReason, blocks }; - } -} diff --git a/packages/cli/src/llm/create-provider.ts b/packages/cli/src/llm/create-provider.ts deleted file mode 100644 index 527a28a..0000000 --- a/packages/cli/src/llm/create-provider.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { InariConfig } from "../config.js"; -import { AnthropicProvider } from "./anthropic.js"; -import { OpenAICompatibleProvider } from "./openai-compatible.js"; -import type { LLMProvider } from "./types.js"; - -export function createLlmProvider(cfg: InariConfig): LLMProvider { - if (cfg.provider === "anthropic") { - return new AnthropicProvider(cfg.apiKey, cfg.model); - } - return new OpenAICompatibleProvider({ - apiKey: cfg.apiKey, - model: cfg.model, - baseURL: cfg.baseURL, - }); -} diff --git a/packages/cli/src/llm/inari-tools.ts b/packages/cli/src/llm/inari-tools.ts deleted file mode 100644 index 5f52bf7..0000000 --- a/packages/cli/src/llm/inari-tools.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { InariToolDefinition } from "./types.js"; - -/** Tools that mutate disk or run shell — omitted in read-only mode. */ -export const MUTATING_TOOL_NAMES = new Set([ - "write_file", - "search_replace", - "apply_patch", - "run_terminal_cmd", -]); - -export function selectToolsForMode(defs: InariToolDefinition[], readOnly: boolean): InariToolDefinition[] { - if (!readOnly) return defs; - return defs.filter((d) => !MUTATING_TOOL_NAMES.has(d.name)); -} - -/** BM25 keyword search via optional Python sidecar (Phase 3). */ -export const CODEBASE_SEARCH_TOOL: InariToolDefinition = { - name: "codebase_search", - description: - "Search the workspace for natural-language or keyword queries using BM25 ranking (Python sidecar). " + - "Respects .inariignore when pathspec is installed. Use for broad ‘where is X?’ questions before narrow grep.", - input_schema: { - type: "object", - properties: { - query: { type: "string", description: "Search query (keywords or short phrase)" }, - max_results: { type: "integer", description: "Max snippets to return (default 12)" }, - max_files: { type: "integer", description: "Max files to index cap (default 1500)" }, - }, - required: ["query"], - }, -}; - -/** Vector / embedding search via OpenAI-compatible `/embeddings` (Phase 3+). */ -export const SEMANTIC_CODEBASE_SEARCH_TOOL: InariToolDefinition = { - name: "semantic_codebase_search", - description: - "Semantic search over the workspace using embedding cosine similarity (OpenAI-compatible /embeddings API). " + - "Builds/updates a cache under .inaricode/semantic-cache-v1.json. Prefer for paraphrase / concept queries vs keyword BM25.", - input_schema: { - type: "object", - properties: { - query: { type: "string", description: "Natural-language query" }, - max_results: { type: "integer", description: "Max hits (default 15)" }, - max_files: { type: "integer", description: "Max source files to scan (default 500)" }, - refresh_index: { - type: "boolean", - description: "If true, drop cache and re-embed all scanned files (slow)", - }, - }, - required: ["query"], - }, -}; - -export function chatToolDefinitions( - readOnly: boolean, - includeCodebaseSearch: boolean, - includeSemanticSearch: boolean, -): InariToolDefinition[] { - let defs: InariToolDefinition[] = [...INARI_TOOL_DEFINITIONS]; - if (includeCodebaseSearch) defs = [...defs, CODEBASE_SEARCH_TOOL]; - if (includeSemanticSearch) defs = [...defs, SEMANTIC_CODEBASE_SEARCH_TOOL]; - return selectToolsForMode(defs, readOnly); -} - -/** Tool names the driver may expose after read-only / sidecar / embeddings toggles. */ -export function knownChatToolNames(opts: { - readOnly: boolean; - includeCodebaseSearch: boolean; - includeSemanticSearch: boolean; -}): Set { - return new Set( - chatToolDefinitions(opts.readOnly, opts.includeCodebaseSearch, opts.includeSemanticSearch).map((d) => d.name), - ); -} - -/** When skill packs are active, keep only tools listed in the merged allowlist. */ -export function applySkillToolAllowlist( - defs: InariToolDefinition[], - allow: Set | null, -): InariToolDefinition[] { - if (!allow || allow.size === 0) return defs; - return defs.filter((d) => allow.has(d.name)); -} - -/** Engine-backed tools (same JSON schema for Anthropic and OpenAI-compatible APIs). */ -export const INARI_TOOL_DEFINITIONS: InariToolDefinition[] = [ - { - name: "read_file", - description: - "Read a UTF-8 text file under the workspace. Optional 1-based line range via start_line / end_line.", - input_schema: { - type: "object", - properties: { - path: { type: "string", description: "Path relative to workspace root" }, - start_line: { type: "integer", description: "First line (1-based), optional" }, - end_line: { type: "integer", description: "Last line inclusive (1-based), optional" }, - }, - required: ["path"], - }, - }, - { - name: "write_file", - description: "Create or overwrite a file under the workspace with the given UTF-8 content.", - input_schema: { - type: "object", - properties: { - path: { type: "string" }, - content: { type: "string" }, - }, - required: ["path", "content"], - }, - }, - { - name: "list_dir", - description: "List entries in a directory under the workspace (non-recursive).", - input_schema: { - type: "object", - properties: { - path: { type: "string", description: "Relative directory path; default '.'" }, - max_entries: { type: "integer", description: "Max entries (default 500)" }, - }, - }, - }, - { - name: "grep", - description: - "Search files under the workspace with a Rust regex pattern. Respects .gitignore via the engine.", - input_schema: { - type: "object", - properties: { - pattern: { type: "string", description: "Rust regex" }, - max_matches: { type: "integer" }, - path_prefix: { type: "string", description: "Only paths starting with this relative prefix" }, - }, - required: ["pattern"], - }, - }, - { - name: "symbol_outline", - description: - "List symbols in a source file. TypeScript/JavaScript/TSX/JSX use tree-sitter when available (classes, functions, interfaces, methods, consts); Python, Rust, Go use line regex heuristics; TS/JS falls back to regex if parsing fails.", - input_schema: { - type: "object", - properties: { - path: { type: "string", description: "File path relative to workspace root" }, - }, - required: ["path"], - }, - }, - { - name: "search_replace", - description: - "Replace old_string with new_string in a file. Unless replace_all is true, old_string must match exactly once.", - input_schema: { - type: "object", - properties: { - path: { type: "string" }, - old_string: { type: "string" }, - new_string: { type: "string" }, - replace_all: { type: "boolean" }, - }, - required: ["path", "old_string", "new_string"], - }, - }, - { - name: "apply_patch", - description: - "Apply a unified diff to an existing file under the workspace (single-file patch). File must exist; hunks must match current content.", - input_schema: { - type: "object", - properties: { - path: { type: "string", description: "Relative path to the file to patch" }, - unified_diff: { type: "string", description: "Unified diff (e.g. ---/+++ style) applying to that file" }, - }, - required: ["path", "unified_diff"], - }, - }, - { - name: "run_terminal_cmd", - description: - "Run a shell command with optional cwd relative to workspace. Subject to user approval in safe mode.", - input_schema: { - type: "object", - properties: { - command: { type: "string" }, - cwd: { type: "string", description: "Working directory relative to workspace; default '.'" }, - timeout_ms: { type: "integer" }, - }, - required: ["command"], - }, - }, -]; diff --git a/packages/cli/src/llm/openai-compatible.ts b/packages/cli/src/llm/openai-compatible.ts deleted file mode 100644 index 92d06f5..0000000 --- a/packages/cli/src/llm/openai-compatible.ts +++ /dev/null @@ -1,184 +0,0 @@ -import https from "node:https"; -import OpenAI from "openai"; -import type { ChatCompletionMessageParam, ChatCompletionTool } from "openai/resources/chat/completions"; -import type { AgentHistoryItem, CompleteResult, InariToolDefinition, LLMProvider, NormalizedBlock } from "./types.js"; -import { withRetry } from "../utils/retry-executor.js"; - -const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 10 }); - -function toOpenAiTools(defs: InariToolDefinition[]): ChatCompletionTool[] { - return defs.map((d) => ({ - type: "function" as const, - function: { - name: d.name, - description: d.description, - parameters: d.input_schema, - }, - })); -} - -function historyToOpenAiMessages(history: AgentHistoryItem[]): ChatCompletionMessageParam[] { - const out: ChatCompletionMessageParam[] = []; - for (const h of history) { - if (h.kind === "user_text") { - out.push({ role: "user", content: h.text }); - } else if (h.kind === "assistant") { - const textParts = h.blocks.filter((b): b is Extract => b.type === "text"); - const toolParts = h.blocks.filter((b): b is Extract => b.type === "tool_use"); - const text = textParts.map((b) => b.text).join("") || null; - if (toolParts.length === 0) { - out.push({ role: "assistant", content: text }); - } else { - out.push({ - role: "assistant", - content: text, - tool_calls: toolParts.map((tu) => ({ - id: tu.id, - type: "function" as const, - function: { - name: tu.name, - arguments: JSON.stringify(tu.input ?? {}), - }, - })), - }); - } - } else if (h.kind === "tool_outputs") { - for (const o of h.outputs) { - out.push({ - role: "tool", - tool_call_id: o.id, - content: o.content, - }); - } - } - } - return out; -} - -export type OpenAICompatibleOptions = { - apiKey: string; - model: string; - baseURL: string; -}; - -/** ChatGPT, Kimi (Moonshot), Qwen (DashScope compatible), Ollama/Llama, Groq, Together, Azure-compatible, etc. */ -export class OpenAICompatibleProvider implements LLMProvider { - private readonly client: OpenAI; - private readonly model: string; - - constructor(opts: OpenAICompatibleOptions) { - this.client = new OpenAI({ - apiKey: opts.apiKey, - baseURL: opts.baseURL, - httpAgent: httpsAgent, - }); - this.model = opts.model; - } - - async complete(params: { - system: string; - history: AgentHistoryItem[]; - tools: InariToolDefinition[]; - onTextDelta?: (chunk: string) => void; - signal?: AbortSignal; - }): Promise { - const userMessages = historyToOpenAiMessages(params.history); - const messages: ChatCompletionMessageParam[] = [ - { role: "system", content: params.system }, - ...userMessages, - ]; - const tools = toOpenAiTools(params.tools); - const body = { - model: this.model, - messages, - tools: tools.length > 0 ? tools : undefined, - tool_choice: tools.length > 0 ? ("auto" as const) : undefined, - temperature: 0.2, - }; - - if (params.onTextDelta) { - const { finishReason, textAcc, toolAcc } = await withRetry(async () => { - const stream = await this.client.chat.completions.create( - { ...body, stream: true }, - { signal: params.signal }, - ); - let finishReason: string | null = null; - let textAcc = ""; - const toolAcc = new Map(); - for await (const chunk of stream) { - const ch = chunk.choices[0]; - if (ch?.finish_reason) finishReason = ch.finish_reason; - const delta = ch?.delta; - if (delta?.content) { - textAcc += delta.content; - if (params.onTextDelta) params.onTextDelta(delta.content); - } - if (delta?.tool_calls) { - for (const tc of delta.tool_calls) { - const idx = tc.index ?? 0; - let row = toolAcc.get(idx); - if (!row) { - row = { id: "", name: "", args: "" }; - toolAcc.set(idx, row); - } - if (tc.id) row.id = tc.id; - if (tc.function?.name) row.name = tc.function.name; - if (tc.function?.arguments) row.args += tc.function.arguments; - } - } - } - return { finishReason, textAcc, toolAcc }; - }); - const blocks: NormalizedBlock[] = []; - if (textAcc) blocks.push({ type: "text", text: textAcc }); - const sorted = [...toolAcc.entries()].sort((a, b) => a[0] - b[0]); - for (const [idx, row] of sorted) { - if (!row.name) continue; - let input: Record; - try { - input = JSON.parse(row.args || "{}") as Record; - } catch { - input = { _parse_error: true, raw: row.args }; - } - blocks.push({ - type: "tool_use", - id: row.id || `tool_${idx}`, - name: row.name, - input, - }); - } - return { stopReason: finishReason, blocks }; - } - - const resp = await withRetry(() => - this.client.chat.completions.create(body, { signal: params.signal }), - ); - - const choice = resp.choices[0]; - const msg = choice?.message; - const blocks: NormalizedBlock[] = []; - if (msg?.content) { - blocks.push({ type: "text", text: msg.content }); - } - for (const tc of msg?.tool_calls ?? []) { - if (tc.type !== "function") continue; - let input: Record; - try { - input = JSON.parse(tc.function.arguments || "{}") as Record; - } catch { - input = { _parse_error: true, raw: tc.function.arguments }; - } - blocks.push({ - type: "tool_use", - id: tc.id, - name: tc.function.name, - input, - }); - } - - return { - stopReason: choice?.finish_reason ?? null, - blocks, - }; - } -} diff --git a/packages/cli/src/llm/types.ts b/packages/cli/src/llm/types.ts deleted file mode 100644 index 70a7c6c..0000000 --- a/packages/cli/src/llm/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type NormalizedBlock = - | { type: "text"; text: string } - | { type: "tool_use"; id: string; name: string; input: Record }; - -/** Serializable chat history shared across providers. */ -export type AgentHistoryItem = - | { kind: "user_text"; text: string } - | { kind: "assistant"; blocks: NormalizedBlock[] } - | { kind: "tool_outputs"; outputs: { id: string; content: string }[] }; - -export type InariToolDefinition = { - name: string; - description: string; - /** JSON Schema object (OpenAI `parameters` / Anthropic `input_schema`). */ - input_schema: Record; -}; - -export type CompleteResult = { - stopReason: string | null; - blocks: NormalizedBlock[]; -}; - -export interface LLMProvider { - complete(params: { - system: string; - history: AgentHistoryItem[]; - tools: InariToolDefinition[]; - /** Stream assistant text as it arrives (tool rounds still buffer until complete). */ - onTextDelta?: (chunk: string) => void; - signal?: AbortSignal; - }): Promise; -} diff --git a/packages/cli/src/mcp/run-mcp-cli.ts b/packages/cli/src/mcp/run-mcp-cli.ts deleted file mode 100644 index 9e759ed..0000000 --- a/packages/cli/src/mcp/run-mcp-cli.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Command } from "commander"; -import { runMcpStdioServer } from "./stdio-mcp.js"; -import type { MessageKey } from "../i18n/strings.js"; -import { resolveWorkspaceRoot } from "../workspace-root.js"; - -type TranslateFn = (key: MessageKey, vars?: Record) => string; - -/** Register `inari mcp` — stdio MCP with read-only engine tools (Phase 7). */ -export function registerMcpCommand(program: Command, tr: TranslateFn): void { - program - .command("mcp") - .description(tr("cmdMcp")) - .option("-r, --root ", tr("optMcpRoot"), "") - .action(async (opts: { root: string }) => { - const cwd = process.cwd(); - const workspaceRoot = resolveWorkspaceRoot(opts.root || undefined, cwd); - await runMcpStdioServer({ workspaceRoot }); - }); -} diff --git a/packages/cli/src/mcp/stdio-mcp.ts b/packages/cli/src/mcp/stdio-mcp.ts deleted file mode 100644 index f274ee5..0000000 --- a/packages/cli/src/mcp/stdio-mcp.ts +++ /dev/null @@ -1,168 +0,0 @@ -import * as readline from "node:readline"; -import { engineRequest, type EngineEnvelope } from "../engine/client.js"; - -const PROTOCOL_VERSION = "2024-11-05"; - -const READ_TOOLS = [ - { - name: "read_file", - description: "Read a UTF-8 file under the workspace (relative path).", - inputSchema: { - type: "object", - properties: { - path: { type: "string" }, - start_line: { type: "integer" }, - end_line: { type: "integer" }, - }, - required: ["path"], - }, - }, - { - name: "list_dir", - description: "List directory entries (non-recursive).", - inputSchema: { - type: "object", - properties: { - path: { type: "string" }, - max_entries: { type: "integer" }, - }, - }, - }, - { - name: "grep", - description: "Rust-regex search; respects .gitignore via engine.", - inputSchema: { - type: "object", - properties: { - pattern: { type: "string" }, - max_matches: { type: "integer" }, - path_prefix: { type: "string" }, - }, - required: ["pattern"], - }, - }, -]; - -function nextId(): string { - return `mcp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; -} - -function textResult(s: string, isError = false): { content: { type: "text"; text: string }[]; isError: boolean } { - return { content: [{ type: "text", text: s }], isError }; -} - -export type RunMcpStdioOptions = { - workspaceRoot: string; -}; - -/** - * Minimal MCP server over stdio (read-only engine tools). Phase 7 — Cursor / Claude Desktop experiments. - */ -export async function runMcpStdioServer(opts: RunMcpStdioOptions): Promise { - const workspace = opts.workspaceRoot; - const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); - - const exec = async (cmd: string, payload: Record): Promise => { - const env: EngineEnvelope = { - id: nextId(), - cmd, - workspace, - payload, - }; - const reply = await engineRequest(env); - if (reply.ok) { - return typeof reply.result === "string" ? reply.result : JSON.stringify(reply.result, null, 2); - } - return `Error: ${reply.error}`; - }; - - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) continue; - let msg: { jsonrpc?: string; id?: string | number; method?: string; params?: unknown }; - try { - msg = JSON.parse(trimmed) as typeof msg; - } catch { - continue; - } - if (msg.jsonrpc !== "2.0") continue; - - const id = msg.id; - const reply = (result: unknown) => { - if (id === undefined) return; - process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`); - }; - const errReply = (code: number, message: string) => { - if (id === undefined) return; - process.stdout.write( - `${JSON.stringify({ - jsonrpc: "2.0", - id, - error: { code, message }, - })}\n`, - ); - }; - - try { - switch (msg.method) { - case "initialize": - reply({ - protocolVersion: PROTOCOL_VERSION, - capabilities: { tools: {} }, - serverInfo: { name: "inaricode-mcp", version: "0.1.0" }, - }); - break; - case "notifications/initialized": - break; - case "tools/list": - reply({ tools: READ_TOOLS }); - break; - case "tools/call": { - const p = msg.params as { name?: string; arguments?: Record }; - const name = p?.name; - const args = p?.arguments ?? {}; - if (name === "read_file") { - const path = args.path; - if (typeof path !== "string") { - errReply(-32602, "read_file requires path"); - break; - } - const payload: Record = { path }; - if (typeof args.start_line === "number") payload.start_line = args.start_line; - if (typeof args.end_line === "number") payload.end_line = args.end_line; - const out = await exec("read_file", payload); - reply(textResult(out, out.startsWith("Error:"))); - } else if (name === "list_dir") { - const payload: Record = { - path: typeof args.path === "string" ? args.path : ".", - }; - if (typeof args.max_entries === "number") payload.max_entries = args.max_entries; - const out = await exec("list_dir", payload); - reply(textResult(out, out.startsWith("Error:"))); - } else if (name === "grep") { - const pattern = args.pattern; - if (typeof pattern !== "string") { - errReply(-32602, "grep requires pattern"); - break; - } - const payload: Record = { pattern }; - if (typeof args.max_matches === "number") payload.max_matches = args.max_matches; - if (typeof args.path_prefix === "string") payload.path_prefix = args.path_prefix; - const out = await exec("grep", payload); - reply(textResult(out, out.startsWith("Error:"))); - } else { - errReply(-32601, `unknown tool: ${String(name)}`); - } - break; - } - case "ping": - reply({}); - break; - default: - errReply(-32601, `method not found: ${String(msg.method)}`); - } - } catch (e) { - errReply(-32603, String(e)); - } - } -} diff --git a/packages/cli/src/media/hf-image.ts b/packages/cli/src/media/hf-image.ts deleted file mode 100644 index 39287ca..0000000 --- a/packages/cli/src/media/hf-image.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Hugging Face Inference API (classic) — text-to-image. - * @see https://huggingface.co/docs/api-inference - */ -export async function huggingFaceTextToImage(params: { - token: string; - model: string; - prompt: string; - signal?: AbortSignal; -}): Promise { - const url = `https://api-inference.huggingface.co/models/${encodeURIComponent(params.model)}`; - const res = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${params.token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ inputs: params.prompt }), - signal: params.signal, - }); - - const ct = (res.headers.get("content-type") ?? "").toLowerCase(); - const buf = Buffer.from(await res.arrayBuffer()); - - if (!res.ok) { - let detail = `${res.status} ${res.statusText}`; - try { - const j = JSON.parse(buf.toString("utf8")) as { error?: string; message?: string }; - if (typeof j.error === "string") detail = j.error; - else if (typeof j.message === "string") detail = j.message; - } catch { - if (buf.length > 0 && buf.length < 800) detail = buf.toString("utf8").trim(); - } - throw new Error(detail); - } - - if (ct.includes("image/")) { - return buf; - } - - try { - const j = JSON.parse(buf.toString("utf8")) as unknown; - const b64 = extractBase64Image(j); - if (b64) return Buffer.from(b64, "base64"); - } catch { - /* fall through */ - } - - throw new Error( - `Unexpected response (content-type: ${ct || "unknown"}). Try another --model or check the model card on Hugging Face.`, - ); -} - -function extractBase64Image(j: unknown): string | null { - if (Array.isArray(j) && j.length > 0) { - const first = j[0] as Record; - if (typeof first.generated_image === "string") { - return stripDataUrl(first.generated_image); - } - if (typeof first.image === "string") { - return stripDataUrl(first.image); - } - } - if (j && typeof j === "object" && "generated_image" in j) { - const v = (j as { generated_image?: string }).generated_image; - if (typeof v === "string") return stripDataUrl(v); - } - return null; -} - -function stripDataUrl(s: string): string { - const m = /^data:image\/\w+;base64,(.+)$/i.exec(s.trim()); - return m?.[1] ?? s; -} diff --git a/packages/cli/src/media/run-media.ts b/packages/cli/src/media/run-media.ts deleted file mode 100644 index 1e3f98c..0000000 --- a/packages/cli/src/media/run-media.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { writeFile } from "node:fs/promises"; -import { loadLocalePreference } from "../i18n/locale.js"; -import { tr } from "../i18n/strings.js"; -import { huggingFaceTextToImage } from "./hf-image.js"; - -function firstEnv(keys: string[]): string | undefined { - for (const k of keys) { - const v = process.env[k]; - if (v && v.length > 0) return v; - } - return undefined; -} - -export async function runMediaImage(opts: { - cwd: string; - prompt: string; - output: string; - model: string; - provider: string; - token?: string; -}): Promise { - const locale = await loadLocalePreference(opts.cwd); - const L = (key: Parameters[1], vars?: Record) => tr(locale, key, vars); - - const provider = opts.provider.toLowerCase(); - if (provider === "google") { - process.stderr.write(`${L("mediaGoogleImageNotImplemented")}\n`); - process.exitCode = 1; - return; - } - if (provider !== "huggingface") { - process.stderr.write(`${L("mediaUnknownProvider", { provider: opts.provider })}\n`); - process.exitCode = 1; - return; - } - - const token = opts.token ?? firstEnv(["HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"]); - if (!token) { - process.stderr.write(`${L("mediaMissingHfToken")}\n`); - process.exitCode = 1; - return; - } - - try { - const bytes = await huggingFaceTextToImage({ - token, - model: opts.model, - prompt: opts.prompt, - }); - await writeFile(opts.output, bytes); - process.stdout.write(`${L("mediaImageWrote", { path: opts.output, bytes: String(bytes.length) })}\n`); - } catch (e) { - process.stderr.write(`${L("mediaImageFail", { detail: String(e) })}\n`); - process.exitCode = 1; - } -} - -export async function runMediaVideo(opts: { cwd: string }): Promise { - const locale = await loadLocalePreference(opts.cwd); - process.stderr.write(`${tr(locale, "mediaVideoStub")}\n`); - process.exitCode = 0; -} diff --git a/packages/cli/src/nyanvim.ts b/packages/cli/src/nyanvim.ts new file mode 100644 index 0000000..828a0cd --- /dev/null +++ b/packages/cli/src/nyanvim.ts @@ -0,0 +1,63 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface IdeContext { + isNeovim: boolean; + isNyanNvim: boolean; + terminal: string; + vimKeybindings: boolean; +} + +export async function isRunningInNeovim(): Promise { + if (process.env.NVIM || process.env.NVIM_APPNAME || process.env.NVIM_TUI_ENABLE_CURSOR) { + return true; + } + return false; +} + +function expandUser(path: string): string { + if (path.startsWith("~/")) { + return join(homedir(), path.slice(2)); + } + return path; +} + +export async function detectNyanNvim(): Promise { + const paths = [ + expandUser("~/.local/share/nvim/site/pack/plugins/start/nyan.nvim"), + expandUser("~/.local/share/nvim/plugged/nyan.nvim"), + expandUser("~/.config/nvim/plugins/nyan.nvim"), + ]; + + const fs = await import("node:fs/promises"); + for (const p of paths) { + try { + await fs.access(p); + return true; + } catch { + continue; + } + } + return false; +} + +export async function detectIde(): Promise { + const [isNeovim, isNyan] = await Promise.all([ + isRunningInNeovim(), + detectNyanNvim(), + ]); + + let terminal = "unknown"; + if (process.env.TERM_PROGRAM === "iTerm.app") terminal = "iterm"; + else if (process.env.TERM_PROGRAM === "Apple_Terminal") terminal = "terminal"; + else if (process.env.WEZTERM_Pane) terminal = "wezterm"; + else if (process.env.KITTMOD_ID) terminal = "kitty"; + else if (process.env.ALACRITTY_SOCKET) terminal = "alacritty"; + + return { + isNeovim, + isNyanNvim: isNyan, + terminal, + vimKeybindings: isNeovim || process.env.INARICODE_VIM_KEYS === "1", + }; +} \ No newline at end of file diff --git a/packages/cli/src/observability/json-log.ts b/packages/cli/src/observability/json-log.ts deleted file mode 100644 index d2f8764..0000000 --- a/packages/cli/src/observability/json-log.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * When **`INARI_LOG=json`**, append one JSON object per line to **stderr** (no ANSI). - * Use for CI / debugging agent loops (Phase 7). - */ -export function inariJsonLog(event: Record): void { - if (process.env.INARI_LOG?.trim().toLowerCase() !== "json") return; - const line = JSON.stringify({ - ts: new Date().toISOString(), - source: "inaricode", - ...event, - }); - process.stderr.write(`${line}\n`); -} diff --git a/packages/cli/src/opencode.ts b/packages/cli/src/opencode.ts new file mode 100644 index 0000000..35b75f4 --- /dev/null +++ b/packages/cli/src/opencode.ts @@ -0,0 +1,175 @@ +import type { ChatCompletionMessageParam } from "openai/resources.mjs"; + +export interface OpenCodeOptions { + baseUrl?: string; + apiKey?: string; + timeout?: number; + model?: string; +} + +export interface ChatOptions { + messages: ChatCompletionMessageParam[]; + model?: string; + temperature?: number; + maxTokens?: number; +} + +export interface ChatResponse { + content: string; + usage: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; +} + +const DEFAULT_URL = "http://localhost:4096"; +const DEFAULT_TIMEOUT = 30000; + +export class OpenCodeClient { + private baseUrl: string; + private apiKey: string; + private timeout: number; + private model: string; + + constructor(opts: OpenCodeOptions = {}) { + this.baseUrl = opts.baseUrl ?? process.env.OPENCODE_URL ?? DEFAULT_URL; + this.apiKey = opts.apiKey ?? process.env.OPENCODE_TOKEN ?? ""; + this.timeout = opts.timeout ?? DEFAULT_TIMEOUT; + this.model = opts.model ?? "claude-sonnet"; + } + + private getUrl(path: string): string { + const base = this.baseUrl.endsWith("/") ? this.baseUrl.slice(0, -1) : this.baseUrl; + return `${base}${path}`; + } + + async chat(opts: ChatOptions): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(this.getUrl("/v1/chat/completions"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}), + }, + body: JSON.stringify({ + model: opts.model ?? this.model, + messages: opts.messages, + temperature: opts.temperature ?? 0.7, + max_tokens: opts.maxTokens, + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`OpenCode API error: ${response.status}`); + } + + const json = await response.json(); + return { + content: json.choices?.[0]?.message?.content ?? "", + usage: json.usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }; + } catch (e) { + clearTimeout(timeoutId); + if (e instanceof Error && e.name === "AbortError") { + throw new Error(`Request timeout after ${this.timeout}ms`); + } + throw e; + } + } + + async chatStream( + opts: ChatOptions, + onChunk: (chunk: string) => void | Promise, + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(this.getUrl("/v1/chat/completions"), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}), + }, + body: JSON.stringify({ + model: opts.model ?? this.model, + messages: opts.messages, + temperature: opts.temperature ?? 0.7, + max_tokens: opts.maxTokens, + stream: true, + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok || !response.body) { + throw new Error(`OpenCode API error: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let content = ""; + let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n").filter((l) => l.startsWith("data: ")); + + for (const line of lines) { + const data = line.slice(6); + if (data === "[DONE]") { + return { content, usage }; + } + try { + const parsed = JSON.parse(data); + const delta = parsed.choices?.[0]?.delta?.content; + if (delta) { + content += delta; + await onChunk(delta); + } + } catch { + continue; + } + } + } + + return { content, usage }; + } finally { + clearTimeout(timeoutId); + } + } + + async ping(): Promise { + try { + const response = await fetch(this.getUrl("/health"), { + method: "GET", + signal: AbortSignal.timeout(5000), + }); + return response.ok; + } catch { + return false; + } + } +} + +let globalClient: OpenCodeClient | undefined; + +export function createClient(opts?: OpenCodeOptions): OpenCodeClient { + globalClient = new OpenCodeClient(opts); + return globalClient; +} + +export function getClient(): OpenCodeClient | undefined { + return globalClient; +} \ No newline at end of file diff --git a/packages/cli/src/pick/collect-files.ts b/packages/cli/src/pick/collect-files.ts deleted file mode 100644 index b510253..0000000 --- a/packages/cli/src/pick/collect-files.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { globby } from "globby"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; - -/** Collect text-ish paths under workspace (gitignore-aware). */ -export async function collectPickCandidates(workspaceRoot: string, globPattern: string): Promise { - const ignoreExtra = await loadInariIgnoreLines(workspaceRoot); - const files = await globby(globPattern, { - cwd: workspaceRoot, - gitignore: true, - ignore: [ - "**/node_modules/**", - "**/.git/**", - "**/dist/**", - "**/target/**", - "**/.inaricode/**", - ...ignoreExtra, - ], - onlyFiles: true, - dot: false, - followSymbolicLinks: false, - }); - const max = 25_000; - files.sort((a, b) => a.localeCompare(b)); - return files.slice(0, max); -} - -async function loadInariIgnoreLines(workspaceRoot: string): Promise { - try { - const raw = await readFile(join(workspaceRoot, ".inariignore"), "utf8"); - return raw - .split(/\r?\n/) - .map((l) => l.trim()) - .filter((l) => l && !l.startsWith("#")); - } catch { - return []; - } -} diff --git a/packages/cli/src/pick/fzf.ts b/packages/cli/src/pick/fzf.ts deleted file mode 100644 index d0cea4b..0000000 --- a/packages/cli/src/pick/fzf.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { spawn } from "node:child_process"; - -/** - * Pipe `lines` to fzf; return chosen line or null if missing/cancelled/error. - */ -export function pickWithFzf( - lines: string[], - fzfPath: string, - prompt: string, -): Promise { - return new Promise((resolve) => { - const child = spawn(fzfPath, ["--height", "40%", "--reverse", "--prompt", `${prompt} `], { - stdio: ["pipe", "pipe", "inherit"], - }); - const out: Buffer[] = []; - child.stdout.on("data", (c: Buffer) => out.push(Buffer.from(c))); - child.on("error", () => resolve(null)); - child.on("close", (code) => { - if (code !== 0) { - resolve(null); - return; - } - const line = Buffer.concat(out).toString("utf8").trim().split("\n")[0]; - resolve(line || null); - }); - child.stdin.write(lines.join("\n")); - child.stdin.end(); - }); -} diff --git a/packages/cli/src/pick/pick-tui.tsx b/packages/cli/src/pick/pick-tui.tsx deleted file mode 100644 index 4834f10..0000000 --- a/packages/cli/src/pick/pick-tui.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { Box, Text, useApp, useInput } from "ink"; -import { filterFuzzySorted } from "../fuzzy/match.js"; - -const WIN = 14; - -export function PickTui(props: { - items: string[]; - title: string; - onChoose: (line: string) => void; - onCancel: () => void; -}) { - const { exit } = useApp(); - const [q, setQ] = useState(""); - const [idx, setIdx] = useState(0); - - const filtered = useMemo(() => { - const f = filterFuzzySorted(q, props.items, 20_000); - return f.length > 0 ? f : props.items.slice(0, Math.min(500, props.items.length)); - }, [q, props.items]); - - useEffect(() => { - setIdx((i) => (filtered.length === 0 ? 0 : Math.min(i, filtered.length - 1))); - }, [filtered.length, q]); - - const start = useMemo(() => { - if (filtered.length <= WIN) return 0; - const half = Math.floor(WIN / 2); - return Math.max(0, Math.min(idx - half, filtered.length - WIN)); - }, [filtered.length, idx]); - - const windowed = useMemo(() => filtered.slice(start, start + WIN), [filtered, start]); - - useInput((input, key) => { - if (key.escape) { - props.onCancel(); - exit(); - return; - } - if (key.return) { - const sel = filtered[idx]; - if (sel) props.onChoose(sel); - exit(); - return; - } - if (key.upArrow) { - setIdx((i) => Math.max(0, i - 1)); - return; - } - if (key.downArrow) { - setIdx((i) => Math.min(filtered.length - 1, i + 1)); - return; - } - if (key.backspace || key.delete) { - setQ((x) => x.slice(0, -1)); - return; - } - if (input && input.length === 1 && !key.ctrl && !key.meta) { - setQ((x) => x + input); - } - }); - - return ( - - {props.title} - - › {q} - - - {windowed.map((line, i) => { - const globalIdx = start + i; - const active = globalIdx === idx; - return ( - - {active ? "▶ " : " "} - {line} - - ); - })} - - - ↑↓ Enter · Esc · filter as you type (fuzzy) - - - ); -} diff --git a/packages/cli/src/pick/run-pick.ts b/packages/cli/src/pick/run-pick.ts deleted file mode 100644 index 2fb0d65..0000000 --- a/packages/cli/src/pick/run-pick.ts +++ /dev/null @@ -1,106 +0,0 @@ -import React from "react"; -import { resolve } from "node:path"; -import { render } from "ink"; -import { loadPickerSettings } from "../config.js"; -import { tr } from "../i18n/strings.js"; -import { loadLocalePreference } from "../i18n/locale.js"; -import { collectPickCandidates } from "./collect-files.js"; -import { pickWithFzf } from "./fzf.js"; -import { PickTui } from "./pick-tui.js"; - -export type RunPickOptions = { - cwd: string; - workspaceRoot: string; - /** Glob pattern; defaults to config `picker.defaultFileGlob` or all files. */ - glob?: string; - /** Override config: builtin | fzf */ - picker?: "builtin" | "fzf"; -}; - -/** Same glob/rules as `pickOneRelativePath`, without opening a picker (for `/pick` preflight). */ -export async function listPickCandidatePaths(opts: RunPickOptions): Promise { - const { glob } = await resolvePickModeAndGlob(opts); - return collectPickCandidates(opts.workspaceRoot, glob); -} - -async function resolvePickModeAndGlob(opts: RunPickOptions): Promise<{ - mode: "builtin" | "fzf"; - glob: string; - fzfPath: string; - locale: Awaited>; -}> { - const locale = await loadLocalePreference(opts.cwd); - const settings = await loadPickerSettings(opts.cwd); - const envPicker = - process.env.INARI_PICKER === "fzf" - ? "fzf" - : process.env.INARI_PICKER === "builtin" - ? "builtin" - : undefined; - const mode = opts.picker ?? envPicker ?? settings.mode; - const glob = opts.glob ?? settings.defaultFileGlob; - return { mode, glob, fzfPath: settings.fzfPath, locale }; -} - -/** - * Interactive file pick; returns **relative** path from `workspaceRoot`, or `null` if cancelled / none. - */ -export async function pickOneRelativePath(opts: RunPickOptions): Promise { - const { mode, glob, fzfPath, locale } = await resolvePickModeAndGlob(opts); - const paths = await collectPickCandidates(opts.workspaceRoot, glob); - if (paths.length === 0) { - return null; - } - - const title = tr(locale, "pickTitle", { count: String(paths.length) }); - let choice: string | null = null; - - if (mode === "fzf") { - choice = await pickWithFzf(paths, fzfPath, "inari"); - if (choice === null) { - process.stderr.write(`${tr(locale, "pickFzfFallback")}\n`); - } - } - - if (choice === null) { - let chosen: string | null = null; - let cancelled = false; - const { waitUntilExit } = render( - React.createElement(PickTui, { - items: paths, - title, - onChoose: (line: string) => { - chosen = line; - }, - onCancel: () => { - cancelled = true; - }, - }), - ); - await waitUntilExit(); - if (cancelled) { - return null; - } - choice = chosen; - } - - return choice; -} - -export async function runPick(opts: RunPickOptions): Promise { - const locale = await loadLocalePreference(opts.cwd); - const { glob } = await resolvePickModeAndGlob(opts); - const paths = await collectPickCandidates(opts.workspaceRoot, glob); - if (paths.length === 0) { - process.stderr.write(`${tr(locale, "pickNoMatches")}\n`); - process.exitCode = 1; - return; - } - const choice = await pickOneRelativePath(opts); - if (!choice) { - process.stderr.write(`${tr(locale, "pickCancelled")}\n`); - process.exitCode = 1; - return; - } - process.stdout.write(`${resolve(opts.workspaceRoot, choice)}\n`); -} diff --git a/packages/cli/src/pkg-meta.ts b/packages/cli/src/pkg-meta.ts deleted file mode 100644 index c3fc57c..0000000 --- a/packages/cli/src/pkg-meta.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { existsSync, readFileSync, statSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { flowerForSemver, parseSemverParts } from "./release-flowers.js"; - -type InariCliPackageJson = { - version: string; - inaricode?: { codename?: string }; -}; - -let cachedSemver: string | null = null; -let cachedPkg: InariCliPackageJson | null = null; - -/** Root of `@inaricode/cli` on disk (`package.json` directory). */ -export function cliPackageRootDir(): string { - const distDir = dirname(fileURLToPath(import.meta.url)); - return join(distDir, ".."); -} - -/** - * Monorepo checkout: `packages/skills/examples` beside `packages/cli`. - * Omitted from the published npm package — returns `null` for normal installs. - */ -export function resolveBundledSkillsExamplesDir(): string | null { - const candidate = join(cliPackageRootDir(), "..", "skills", "examples"); - try { - if (existsSync(candidate) && statSync(candidate).isDirectory()) { - return candidate; - } - } catch { - /* ignore */ - } - return null; -} - -function readCliPackageJson(): InariCliPackageJson { - if (cachedPkg) return cachedPkg; - const p = join(cliPackageRootDir(), "package.json"); - cachedPkg = JSON.parse(readFileSync(p, "utf8")) as InariCliPackageJson; - return cachedPkg; -} - -/** Semver from `package.json` only (e.g. `0.1.0`). */ -export function cliPackageVersion(): string { - if (cachedSemver) return cachedSemver; - cachedSemver = readCliPackageJson().version; - return cachedSemver; -} - -export type CliReleaseMeta = { - /** Full semver string from package.json */ - semver: string; - major: number; - minor: number; - patch: number; - /** Flower / release name (override or derived from semver) */ - codename: string; -}; - -/** Parsed semver, patch level, and flower codename for banners and `--version`. */ -export function cliPackageReleaseMeta(): CliReleaseMeta { - const pkg = readCliPackageJson(); - const semver = pkg.version; - const parts = parseSemverParts(semver) ?? { major: 0, minor: 1, patch: 0 }; - const override = pkg.inaricode?.codename?.trim(); - const codename = override && override.length > 0 ? override : flowerForSemver(parts); - return { - semver, - major: parts.major, - minor: parts.minor, - patch: parts.patch, - codename, - }; -} - -/** Single line for CLI UI: version, patch, and flower name. */ -export function cliVersionLine(): string { - const { semver, patch, codename } = cliPackageReleaseMeta(); - return `v${semver} · patch ${patch} · ${codename}`; -} diff --git a/packages/cli/src/policy/shell.ts b/packages/cli/src/policy/shell.ts deleted file mode 100644 index ccca5db..0000000 --- a/packages/cli/src/policy/shell.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** Default deny rules (case-insensitive substring match on trimmed command). */ -export const DEFAULT_SHELL_DENY_SUBSTRINGS: string[] = [ - "rm -rf /", - "rm -rf ~/", - "mkfs.", - "dd if=", - "> /dev/sd", - "| sh", - "| bash", - "curl |", - "wget |", -]; - -export type ResolvedShellPolicy = { - denySubstrings: string[]; - /** If non-empty, the trimmed command must start with one of these prefixes */ - allowCommandPrefixes: string[]; -}; - -export type ShellPolicyConfig = { - denySubstrings?: string[]; - allowCommandPrefixes?: string[]; -}; - -export function resolveShellPolicy(config?: ShellPolicyConfig): ResolvedShellPolicy { - const deny = [...DEFAULT_SHELL_DENY_SUBSTRINGS, ...(config?.denySubstrings ?? [])]; - const allow = (config?.allowCommandPrefixes ?? []).filter((s) => s.length > 0); - return { denySubstrings: deny, allowCommandPrefixes: allow }; -} - -export function assertShellAllowed(command: string, policy: ResolvedShellPolicy): void { - const c = command.trim().toLowerCase(); - for (const bad of policy.denySubstrings) { - if (c.includes(bad.toLowerCase())) { - throw new Error(`Command blocked by policy (matched "${bad}")`); - } - } - const t = command.trim(); - if (policy.allowCommandPrefixes.length > 0) { - const ok = policy.allowCommandPrefixes.some((p) => t.startsWith(p)); - if (!ok) { - throw new Error( - `Command not allowed: must start with one of: ${policy.allowCommandPrefixes.join(", ")}`, - ); - } - } -} diff --git a/packages/cli/src/providers.ts b/packages/cli/src/providers.ts new file mode 100644 index 0000000..b6cc9e7 --- /dev/null +++ b/packages/cli/src/providers.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +export const ProviderIdSchema = z.enum([ + "opencode", + "anthropic", + "openai", + "kimi", + "ollama", + "groq", + "google", + "custom", +]); + +export type ProviderId = z.infer; + +export type OpenAiPreset = { + baseURL: string; + defaultModel: string; + envKeys: string[]; + apiKeyOptional?: boolean; +}; + +export const OPENAI_PRESETS: Record, OpenAiPreset> = { + openai: { + baseURL: "https://api.openai.com/v1", + defaultModel: "gpt-4o-mini", + envKeys: ["OPENAI_API_KEY"], + }, + kimi: { + baseURL: "https://api.moonshot.cn/v1", + defaultModel: "moonshot-v1-8k", + envKeys: ["MOONSHOT_API_KEY"], + }, + ollama: { + baseURL: "http://127.0.0.1:11434/v1", + defaultModel: "llama3.2", + envKeys: ["OLLAMA_API_KEY"], + apiKeyOptional: true, + }, + groq: { + baseURL: "https://api.groq.com/openai/v1", + defaultModel: "llama-3.3-70b-versatile", + envKeys: ["GROQ_API_KEY"], + }, + google: { + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + defaultModel: "gemini-2.0-flash", + envKeys: ["GOOGLE_API_KEY"], + }, + custom: { + baseURL: "", + defaultModel: "gpt-4o-mini", + envKeys: ["OPENAI_API_KEY"], + }, +}; + +export function getProviderPreset(id: string): OpenAiPreset | undefined { + return OPENAI_PRESETS[id as keyof typeof OPENAI_PRESETS]; +} \ No newline at end of file diff --git a/packages/cli/src/providers/catalog.ts b/packages/cli/src/providers/catalog.ts deleted file mode 100644 index 460e031..0000000 --- a/packages/cli/src/providers/catalog.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { ProviderId } from "../config.js"; -import { ANTHROPIC_DEFAULT_MODEL, OPENAI_PRESETS } from "../config.js"; - -export type ProviderCatalogEntry = { - id: string; - /** anthropic | openai_compat | cursor_cloud */ - backend: "anthropic" | "openai_compat" | "cursor_cloud"; - label: string; - defaultModel: string; - envKeys: string[]; - baseURL: string; - /** How to use from this CLI */ - usage: "inaricode.yaml / inaricode.config.cjs provider + model, or INARI_PROVIDER / INARI_MODEL, or inari chat --provider …" | "inari cursor … (CURSOR_API_KEY); not a REPL chat backend"; -}; - -const ANTHROPIC_ROW: ProviderCatalogEntry = { - id: "anthropic", - backend: "anthropic", - label: "Anthropic Claude", - defaultModel: ANTHROPIC_DEFAULT_MODEL, - envKeys: ["ANTHROPIC_API_KEY"], - baseURL: "(native Messages API)", - usage: "inaricode.yaml / inaricode.config.cjs provider + model, or INARI_PROVIDER / INARI_MODEL, or inari chat --provider …", -}; - -const CURSOR_ROW: ProviderCatalogEntry = { - id: "cursor", - backend: "cursor_cloud", - label: "Cursor Cloud Agents", - defaultModel: "(per launch; see inari cursor models)", - envKeys: ["CURSOR_API_KEY"], - baseURL: "https://api.cursor.com", - usage: "inari cursor … (CURSOR_API_KEY); not a REPL chat backend", -}; - -function openAiRows(): ProviderCatalogEntry[] { - const out: ProviderCatalogEntry[] = []; - for (const id of Object.keys(OPENAI_PRESETS) as Exclude[]) { - const p = OPENAI_PRESETS[id]; - out.push({ - id, - backend: "openai_compat", - label: labelForOpenAiPreset(id), - defaultModel: p.defaultModel, - envKeys: [...p.envKeys], - baseURL: p.baseURL || "(set baseURL when provider is custom)", - usage: "inaricode.yaml / inaricode.config.cjs provider + model, or INARI_PROVIDER / INARI_MODEL, or inari chat --provider …", - }); - } - return out; -} - -const OPENAI_LABELS: Record, string> = { - openai: "OpenAI (ChatGPT API)", - kimi: "Moonshot / Kimi", - qwen: "Alibaba Qwen (DashScope compatible)", - ollama: "Ollama (local Llama, Mistral, …)", - groq: "Groq (Llama, etc.)", - together: "Together AI", - egune: "Egune (MN)", - eguna: "Egune (alias eguna)", - mongol_ai: "Mongol AI", - huggingface: "Hugging Face router (OpenAI-compatible)", - google: "Google Gemini (OpenAI-compatible)", - custom: "Custom OpenAI-compatible URL", -}; - -function labelForOpenAiPreset(id: Exclude): string { - return OPENAI_LABELS[id]; -} - -/** All rows for `inari providers list` (chat backends + Cursor cloud). */ -export function getProviderCatalog(): ProviderCatalogEntry[] { - return [ANTHROPIC_ROW, ...openAiRows().sort((a, b) => a.id.localeCompare(b.id)), CURSOR_ROW]; -} - -export function getProviderCatalogEntry(id: string): ProviderCatalogEntry | undefined { - return getProviderCatalog().find((e) => e.id === id); -} diff --git a/packages/cli/src/providers/run-providers-cli.ts b/packages/cli/src/providers/run-providers-cli.ts deleted file mode 100644 index 7a42f75..0000000 --- a/packages/cli/src/providers/run-providers-cli.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Command } from "commander"; -import { getProviderCatalog, getProviderCatalogEntry } from "./catalog.js"; -import type { MessageKey } from "../i18n/strings.js"; - -type TranslateFn = (key: MessageKey, vars?: Record) => string; - -export function registerProvidersCommand(program: Command, tr: TranslateFn): void { - const prov = program.command("providers").description(tr("cmdProviders")); - - prov - .command("list") - .description("List chat providers + Cursor cloud (JSON)") - .option("--plain", "Tab-separated table instead of JSON", false) - .action((opts: { plain: boolean }) => { - const rows = getProviderCatalog(); - if (opts.plain) { - process.stdout.write("id\tbackend\tlabel\tdefaultModel\tbaseURL\tenvKeys\n"); - for (const r of rows) { - process.stdout.write( - `${r.id}\t${r.backend}\t${r.label}\t${r.defaultModel}\t${r.baseURL}\t${r.envKeys.join(",")}\n`, - ); - } - return; - } - process.stdout.write(`${JSON.stringify({ providers: rows }, null, 2)}\n`); - }); - - prov - .command("show") - .description("Show one provider by id (e.g. anthropic, ollama, cursor)") - .argument("", "Provider id") - .action((id: string) => { - const e = getProviderCatalogEntry(id.trim().toLowerCase()); - if (!e) { - process.stderr.write(`${tr("providersUnknown", { id })}\n`); - process.exitCode = 1; - return; - } - process.stdout.write(`${JSON.stringify(e, null, 2)}\n`); - }); -} diff --git a/packages/cli/src/release-flowers.ts b/packages/cli/src/release-flowers.ts deleted file mode 100644 index e8f8e50..0000000 --- a/packages/cli/src/release-flowers.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** Deterministic flower codenames for releases (no extra deps). Alphabetized for stable diffs. */ -export const RELEASE_FLOWERS: readonly string[] = [ - "Acacia", - "Allium", - "Amaryllis", - "Anemone", - "Azalea", - "Begonia", - "Bellflower", - "Bergamot", - "Bluebell", - "Buttercup", - "Camellia", - "Carnation", - "Chrysanthemum", - "Clematis", - "Clover", - "Columbine", - "Cornflower", - "Cosmos", - "Dahlia", - "Daisy", - "Delphinium", - "Edelweiss", - "Freesia", - "Gardenia", - "Geranium", - "Heather", - "Hibiscus", - "Honeysuckle", - "Hyacinth", - "Hydrangea", - "Iris", - "Jasmine", - "Lavender", - "Lilac", - "Lotus", - "Magnolia", - "Marigold", - "Orchid", - "Peony", - "Petunia", - "Poppy", - "Primrose", - "Rhododendron", - "Rose", - "Sakura", - "Snapdragon", - "Sunflower", - "Sweet pea", - "Tulip", - "Verbena", - "Violet", - "Wisteria", - "Zinnia", -] as const; - -export type SemverParts = { major: number; minor: number; patch: number }; - -/** Parse leading X.Y.Z from semver strings (ignores prerelease suffix). */ -export function parseSemverParts(version: string): SemverParts | null { - const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim()); - if (!m) return null; - return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }; -} - -/** Stable flower index from semver (same version → same flower). */ -export function flowerForSemver(parts: SemverParts): string { - const n = parts.major * 10_007 + parts.minor * 1_009 + parts.patch; - const idx = n % RELEASE_FLOWERS.length; - return RELEASE_FLOWERS[idx] ?? "Bloom"; -} diff --git a/packages/cli/src/session/compact-history.ts b/packages/cli/src/session/compact-history.ts deleted file mode 100644 index 36c4abc..0000000 --- a/packages/cli/src/session/compact-history.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { AgentHistoryItem } from "../llm/types.js"; - -/** Split history into segments, each starting with a `user_text` item. */ -export function segmentHistoryByUserTurns(history: AgentHistoryItem[]): AgentHistoryItem[][] { - const segments: AgentHistoryItem[][] = []; - let cur: AgentHistoryItem[] = []; - for (const item of history) { - if (item.kind === "user_text") { - if (cur.length > 0) segments.push(cur); - cur = [item]; - } else { - cur.push(item); - } - } - if (cur.length > 0) segments.push(cur); - return segments; -} - -/** - * Keep only the last `keepUserTurns` user-led segments (each segment begins with `user_text`). - * Drops older context to reduce tokens on the next model call. Does not summarize — lossy trim. - */ -export function compactHistoryByUserTurns( - history: AgentHistoryItem[], - keepUserTurns: number, -): AgentHistoryItem[] { - if (keepUserTurns < 1) return history; - const segments = segmentHistoryByUserTurns(history); - if (segments.length <= keepUserTurns) return history; - return segments.slice(-keepUserTurns).flat(); -} diff --git a/packages/cli/src/session/context-compact.ts b/packages/cli/src/session/context-compact.ts deleted file mode 100644 index 1331f30..0000000 --- a/packages/cli/src/session/context-compact.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** Context compaction: intelligently trim conversation history to stay within provider limits. */ - -import type { AgentHistoryItem, LLMProvider, NormalizedBlock } from "../llm/types.js"; -import { summarizeHistory } from "./summarize-history.js"; - -export type SummarizationConfig = { - enabled: boolean; - /** Char count threshold above which summarization fires (default: 120_000) */ - threshold: number; - /** Recent user-led turns to keep unsummarized (default: 4) */ - keepRecentTurns: number; -}; - -export type CompactionOptions = { - /** Maximum total characters in history (default: 200_000) */ - maxChars?: number; - /** Minimum user turns to always keep (default: 2) */ - minUserTurns?: number; - /** Prefer compacting tool_outputs first (default: true) */ - compressToolOutputsFirst?: boolean; - /** Truncate tool output content to this many chars during compaction */ - toolOutputTruncateChars?: number; -}; - -const DEFAULT_OPTS: Required = { - maxChars: 200_000, - minUserTurns: 2, - compressToolOutputsFirst: true, - toolOutputTruncateChars: 500, -}; - -function countChars(history: AgentHistoryItem[]): number { - let total = 0; - for (const h of history) { - if (h.kind === "user_text") { - total += h.text.length; - } else if (h.kind === "assistant") { - for (const b of h.blocks) { - if (b.type === "text") total += b.text.length; - else total += JSON.stringify(b.input).length; - } - } else if (h.kind === "tool_outputs") { - for (const o of h.outputs) total += o.content.length; - } - } - return total; -} - -/** - * Compact history when approaching provider context limits. - * Strategy: keep recent turns, compress older tool outputs, truncate assistant text. - */ -export function compactHistory(history: AgentHistoryItem[], opts: CompactionOptions = {}): AgentHistoryItem[] { - const options: Required = { ...DEFAULT_OPTS, ...opts }; - const current = [...history]; - - // Fast path: already under limit - if (countChars(current) <= options.maxChars) return current; - - const result: AgentHistoryItem[] = []; - let userTurnsKept = 0; - - // Iterate from newest to oldest, keeping recent turns and compressing older ones - for (let i = current.length - 1; i >= 0; i--) { - const item = current[i]; - const isUserTurn = item.kind === "user_text"; - - if (isUserTurn && userTurnsKept < options.minUserTurns) { - result.unshift(item); - userTurnsKept++; - continue; - } - - // Keep the last 4 turns uncompressed - if (i >= current.length - 4) { - result.unshift(item); - continue; - } - - // Compress tool outputs - if (item.kind === "tool_outputs" && options.compressToolOutputsFirst) { - const truncatedOutputs = item.outputs.map((o) => ({ - id: o.id, - content: - o.content.length > options.toolOutputTruncateChars - ? o.content.slice(0, options.toolOutputTruncateChars) + "…[truncated]" - : o.content, - })); - result.unshift({ kind: "tool_outputs", outputs: truncatedOutputs }); - continue; - } - - // Compress assistant text blocks - if (item.kind === "assistant") { - // Keep only the first text block, compress rest - const textBlocks = item.blocks.filter((b) => b.type === "text"); - if (textBlocks.length > 1) { - const compressedBlocks: NormalizedBlock[] = [textBlocks[0]]; - for (let j = 1; j < textBlocks.length; j++) { - compressedBlocks.push({ - type: "text", - text: textBlocks[j].text.slice(0, 200) + "…[compressed]", - }); - } - const toolBlocks = item.blocks.filter((b) => b.type === "tool_use"); - result.unshift({ kind: "assistant", blocks: [...compressedBlocks, ...toolBlocks] }); - } else { - result.unshift(item); - } - continue; - } - - // Keep everything else as-is - result.unshift(item); - } - - // Final pass: if still over limit, truncate from oldest end - while (countChars(result) > options.maxChars && result.length > options.minUserTurns * 2) { - // Remove oldest non-user item - const idx = result.findIndex((h) => h.kind !== "user_text"); - if (idx >= 0) result.splice(idx, 1); - else break; - } - - return result; -} - -/** - * Summarize old turns with the LLM when over threshold, then run synchronous - * compaction as a safety net. - */ -export async function summarizeAndCompactHistory( - history: AgentHistoryItem[], - provider: LLMProvider, - summarization: SummarizationConfig, - compactOpts: CompactionOptions = {}, -): Promise { - let current = history; - - if (summarization.enabled && countChars(current) > summarization.threshold) { - current = await summarizeHistory(current, { - provider, - keepRecentTurns: summarization.keepRecentTurns, - }); - } - - return compactHistory(current, compactOpts); -} diff --git a/packages/cli/src/session/file-session.ts b/packages/cli/src/session/file-session.ts deleted file mode 100644 index 39c80f9..0000000 --- a/packages/cli/src/session/file-session.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises"; -import type { AgentHistoryItem } from "../llm/types.js"; - -const VERSION = 1; - -export type SessionFileV1 = { - version: typeof VERSION; - history: AgentHistoryItem[]; -}; - -export async function loadSessionFile(path: string): Promise { - try { - const text = await readFile(path, "utf8"); - const data = JSON.parse(text) as SessionFileV1 | { history?: AgentHistoryItem[] }; - if (data && Array.isArray((data as SessionFileV1).history)) { - return (data as SessionFileV1).history; - } - } catch (e: unknown) { - const err = e as NodeJS.ErrnoException; - if (err.code === "ENOENT") return []; - throw e; - } - return []; -} - -export async function saveSessionFile(path: string, history: AgentHistoryItem[]): Promise { - const body: SessionFileV1 = { version: VERSION, history }; - await writeFile(path, `${JSON.stringify(body, null, 2)}\n`, "utf8"); -} diff --git a/packages/cli/src/session/summarize-history.ts b/packages/cli/src/session/summarize-history.ts deleted file mode 100644 index 10627cd..0000000 --- a/packages/cli/src/session/summarize-history.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { AgentHistoryItem, LLMProvider } from "../llm/types.js"; -import { segmentHistoryByUserTurns } from "./compact-history.js"; - -export type SummarizeOptions = { - provider: LLMProvider; - /** User-led turns to keep unsummarized from the end (default: 4) */ - keepRecentTurns?: number; -}; - -/** Render history items as readable text for the summarization prompt. */ -export function renderHistoryAsText(history: AgentHistoryItem[]): string { - const parts: string[] = []; - for (const item of history) { - if (item.kind === "user_text") { - parts.push(`User: ${item.text}`); - } else if (item.kind === "assistant") { - const textParts = item.blocks - .filter((b) => b.type === "text") - .map((b) => (b.type === "text" ? b.text : "")); - const toolNames = item.blocks - .filter((b) => b.type === "tool_use") - .map((b) => (b.type === "tool_use" ? `[called ${b.name}]` : "")); - const combined = [...textParts, ...toolNames].join(" ").trim(); - parts.push(`Assistant: ${combined}`); - } else { - // tool_outputs - const preview = item.outputs - .map((o) => o.content.slice(0, 200)) - .join("; "); - parts.push(`Tool results: ${preview}`); - } - } - return parts.join("\n\n"); -} - -/** - * Summarize old conversation turns using the LLM. - * Replaces all turns older than `keepRecentTurns` user-led segments with a - * single `[Session context summary: …]` user_text item. - * Returns the original reference unchanged when there is nothing old to summarize. - */ -export async function summarizeHistory( - history: AgentHistoryItem[], - opts: SummarizeOptions, -): Promise { - const keepRecentTurns = opts.keepRecentTurns ?? 4; - const segments = segmentHistoryByUserTurns(history); - - if (segments.length <= keepRecentTurns) return history; - - const oldHistory = segments.slice(0, -keepRecentTurns).flat(); - const recentHistory = segments.slice(-keepRecentTurns).flat(); - - const summaryText = await callLLMForSummary(oldHistory, opts.provider); - - return [ - { kind: "user_text", text: `[Session context summary: ${summaryText}]` }, - ...recentHistory, - ]; -} - -async function callLLMForSummary( - history: AgentHistoryItem[], - provider: LLMProvider, -): Promise { - const rendered = renderHistoryAsText(history); - const result = await provider.complete({ - system: - "You are a conversation summarizer. Summarize the following conversation history " + - "in 2-4 concise paragraphs. Preserve: the user's goals, key decisions made, " + - "files read or changed, and current state. Be factual and brief. " + - "Output only the summary, no preamble.", - history: [ - { kind: "user_text", text: `Summarize this conversation:\n\n${rendered}` }, - ], - tools: [], - }); - const textBlock = result.blocks.find((b) => b.type === "text"); - return textBlock?.type === "text" ? textBlock.text : "No summary available."; -} diff --git a/packages/cli/src/sidecar/client.ts b/packages/cli/src/sidecar/client.ts deleted file mode 100644 index 62d672e..0000000 --- a/packages/cli/src/sidecar/client.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { spawn } from "node:child_process"; -import * as readline from "node:readline"; - -export type SidecarRequest = { - id: string; - method: string; - params: Record; -}; - -/** - * One-shot RPC: spawn sidecar, write one JSON line, read one JSON line. - */ -export async function sidecarRpc(argv: string[], req: SidecarRequest, timeoutMs = 120_000): Promise { - if (argv.length === 0) { - throw new Error("sidecar argv is empty"); - } - const child = spawn(argv[0], argv.slice(1), { - stdio: ["pipe", "pipe", "pipe"], - }); - - const stderrChunks: Buffer[] = []; - child.stderr.on("data", (c) => stderrChunks.push(Buffer.from(c))); - - const lineReader = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); - - const errText = () => Buffer.concat(stderrChunks).toString("utf8").trim(); - - const linePromise = new Promise((resolve, reject) => { - let settled = false; - const finish = (fn: () => void) => { - if (settled) return; - settled = true; - fn(); - }; - - const t = setTimeout(() => { - finish(() => reject(new Error(`sidecar timeout after ${timeoutMs}ms`))); - }, timeoutMs); - - lineReader.once("line", (ln) => { - clearTimeout(t); - finish(() => resolve(ln)); - }); - child.once("error", (e) => { - clearTimeout(t); - finish(() => reject(e)); - }); - child.once("close", (code) => { - if (code !== 0 && code !== null) { - clearTimeout(t); - finish(() => reject(new Error(errText() || `sidecar exited ${code}`))); - } - }); - }); - - child.stdin.write(`${JSON.stringify(req)}\n`); - child.stdin.end(); - - let raw: string; - try { - raw = await linePromise; - } finally { - lineReader.close(); - } - - let parsed: { ok?: boolean; result?: unknown; error?: string }; - try { - parsed = JSON.parse(raw) as { ok?: boolean; result?: unknown; error?: string }; - } catch (e) { - const err = new Error(`invalid sidecar JSON: ${String(e)}: ${raw.slice(0, 500)}`); - if (e instanceof Error) err.cause = e; - throw err; - } - if (!parsed.ok) { - throw new Error(parsed.error || "sidecar error"); - } - return parsed.result; -} diff --git a/packages/cli/src/sidecar/resolve.ts b/packages/cli/src/sidecar/resolve.ts deleted file mode 100644 index e7fbe72..0000000 --- a/packages/cli/src/sidecar/resolve.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -/** Split a command string into argv (no shell); paths with spaces should use `sidecar.command` carefully. */ -export function parseSidecarCommand(cmd: string): string[] { - return cmd - .trim() - .split(/\s+/) - .map((s) => s.trim()) - .filter(Boolean); -} - -export function defaultSidecarScriptPath(): string | null { - const here = dirname(fileURLToPath(import.meta.url)); - const candidates = [ - join(here, "..", "..", "..", "sidecar", "inari_sidecar.py"), - join(here, "..", "..", "..", "..", "packages", "sidecar", "inari_sidecar.py"), - ]; - for (const c of candidates) { - if (existsSync(c)) return c; - } - return null; -} - -export function resolveSidecarArgv(options: { enabled: boolean; command?: string }): string[] | null { - if (!options.enabled) return null; - if (options.command) return parseSidecarCommand(options.command); - const env = process.env.INARI_SIDECAR_CMD?.trim(); - if (env) return parseSidecarCommand(env); - const py = process.env.INARI_PYTHON?.trim() || "python3"; - const script = defaultSidecarScriptPath(); - if (!script) return null; - return [py, script]; -} diff --git a/packages/cli/src/skills/load-pack.ts b/packages/cli/src/skills/load-pack.ts deleted file mode 100644 index e09c1b0..0000000 --- a/packages/cli/src/skills/load-pack.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { readFile, stat } from "node:fs/promises"; -import { dirname, extname, isAbsolute, join, resolve } from "node:path"; -import yaml from "js-yaml"; -import { SkillManifestSchema, type SkillManifest } from "./manifest.js"; - -export type LoadedSkillPack = { - manifest: SkillManifest; - /** Absolute path to the directory containing skill.yaml / skill.json */ - rootDir: string; - systemPrompt: string; -}; - -function resolvePackRoot(configPath: string): string { - const ext = extname(configPath).toLowerCase(); - if (ext === ".yaml" || ext === ".yml" || ext === ".json") { - return dirname(configPath); - } - return configPath; -} - -async function findManifestPath(resolvedPath: string): Promise { - let st; - try { - st = await stat(resolvedPath); - } catch { - return null; - } - if (st.isFile()) { - const ext = extname(resolvedPath).toLowerCase(); - if ([".yaml", ".yml", ".json"].includes(ext)) return resolvedPath; - return null; - } - if (st.isDirectory()) { - for (const name of ["skill.yaml", "skill.yml", "skill.json"]) { - const p = join(resolvedPath, name); - try { - const s = await stat(p); - if (s.isFile()) return p; - } catch { - /* try next */ - } - } - } - return null; -} - -function parseManifest(raw: string, path: string): SkillManifest { - const lower = path.toLowerCase(); - const data: unknown = - lower.endsWith(".json") ? JSON.parse(raw) : yaml.load(raw, { filename: path }); - return SkillManifestSchema.parse(data); -} - -/** - * Load one skill pack from a path (directory with skill.yaml, or path to manifest file). - * `cwd` is used to resolve relative paths. - */ -export async function loadSkillPack(cwd: string, packPath: string): Promise { - const abs = isAbsolute(packPath) ? packPath : resolve(cwd, packPath); - const manifestPath = await findManifestPath(abs); - if (!manifestPath) { - throw new Error(`no skill.yaml / skill.yml / skill.json under ${abs}`); - } - const raw = await readFile(manifestPath, "utf8"); - const manifest = parseManifest(raw, manifestPath); - const rootDir = resolvePackRoot(manifestPath); - const promptRel = manifest.system_prompt_file.replace(/^\.\//, ""); - const promptPath = join(rootDir, promptRel); - let systemPrompt: string; - try { - systemPrompt = await readFile(promptPath, "utf8"); - } catch { - throw new Error(`system_prompt_file not found: ${manifest.system_prompt_file} (resolved ${promptPath})`); - } - return { manifest, rootDir, systemPrompt: systemPrompt.trimEnd() }; -} diff --git a/packages/cli/src/skills/manifest.ts b/packages/cli/src/skills/manifest.ts deleted file mode 100644 index 2909438..0000000 --- a/packages/cli/src/skills/manifest.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from "zod"; - -/** Matches `packages/skills/skill.manifest.schema.json` (v0 + optional slash_hints). */ -export const SkillManifestSchema = z.object({ - id: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "id must be kebab-case"), - version: z.string().min(1), - name: z.string().min(1), - description: z.string().min(1), - system_prompt_file: z.string().min(1), - tools_allow: z.array(z.string().min(1)).min(1), - slash_hints: z.array(z.string().min(1)).optional(), -}); - -export type SkillManifest = z.infer; diff --git a/packages/cli/src/skills/read-pack-config.ts b/packages/cli/src/skills/read-pack-config.ts deleted file mode 100644 index 51fbb81..0000000 --- a/packages/cli/src/skills/read-pack-config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { cosmiconfig } from "cosmiconfig"; -import { z } from "zod"; -import { inaricodeConfigSearchPlaces } from "../config-paths.js"; - -const PacksOnlySchema = z.object({ - skills: z.object({ packs: z.array(z.string()).optional() }).optional(), -}); - -/** Read `skills.packs` without validating full inaricode config (no API keys). */ -export async function loadSkillPackPathsFromConfig(searchFrom: string): Promise { - const explorer = cosmiconfig("inaricode", { - searchPlaces: inaricodeConfigSearchPlaces(), - }); - const found = await explorer.search(searchFrom); - const p = PacksOnlySchema.safeParse(found?.config ?? {}); - if (!p.success) return []; - return p.data.skills?.packs ?? []; -} diff --git a/packages/cli/src/skills/resolve-context.ts b/packages/cli/src/skills/resolve-context.ts deleted file mode 100644 index 28c9984..0000000 --- a/packages/cli/src/skills/resolve-context.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { loadSkillPack, type LoadedSkillPack } from "./load-pack.js"; - -export type SkillLoadIssue = { path: string; message: string }; - -export type ResolvedSkillsContext = { - packs: LoadedSkillPack[]; - issues: SkillLoadIssue[]; - /** Union of valid `tools_allow` entries; `null` = do not filter */ - toolAllowlist: Set | null; - systemPromptAppendix: string; - slashHints: string[]; -}; - -/** - * Load all configured packs, merge prompts and slash hints, and build a tool allowlist - * (union of each pack's `tools_allow`, intersected with `knownToolNames`). - */ -export async function resolveSkillsContext( - cwd: string, - packPaths: string[], - knownToolNames: Set, -): Promise { - if (packPaths.length === 0) { - return { packs: [], issues: [], toolAllowlist: null, systemPromptAppendix: "", slashHints: [] }; - } - - const packs: LoadedSkillPack[] = []; - const issues: SkillLoadIssue[] = []; - - for (const p of packPaths) { - try { - packs.push(await loadSkillPack(cwd, p)); - } catch (e) { - issues.push({ path: p, message: String(e) }); - } - } - - const mergedAllow = new Set(); - const slashHints: string[] = []; - const blocks: string[] = []; - - for (const pack of packs) { - let unknown = 0; - for (const t of pack.manifest.tools_allow) { - if (knownToolNames.has(t)) mergedAllow.add(t); - else unknown += 1; - } - if (unknown > 0) { - issues.push({ - path: pack.manifest.id, - message: `${unknown} tool name(s) in tools_allow are not available in this session (read-only / sidecar / embeddings) — ignored`, - }); - } - if (pack.manifest.slash_hints) { - slashHints.push(...pack.manifest.slash_hints); - } - blocks.push( - `### Skill pack: ${pack.manifest.name} (\`${pack.manifest.id}\` @ ${pack.manifest.version})\n\n${pack.systemPrompt}`, - ); - } - - let toolAllowlist: Set | null = null; - if (packs.length > 0) { - if (mergedAllow.size === 0) { - issues.push({ - path: "(skills)", - message: "no usable tools in tools_allow for this session — keeping full tool set", - }); - } else { - toolAllowlist = mergedAllow; - } - } - - const systemPromptAppendix = - blocks.length > 0 - ? `\n\n## Declarative skill packs (YAML/Markdown only; no code execution)\n\n${blocks.join("\n\n---\n\n")}` - : ""; - - return { packs, issues, toolAllowlist, systemPromptAppendix, slashHints }; -} diff --git a/packages/cli/src/skills/run-skills-cli.ts b/packages/cli/src/skills/run-skills-cli.ts deleted file mode 100644 index 147a6c2..0000000 --- a/packages/cli/src/skills/run-skills-cli.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Command } from "commander"; -import { knownChatToolNames } from "../llm/inari-tools.js"; -import { loadSkillPack } from "./load-pack.js"; -import { loadSkillPackPathsFromConfig } from "./read-pack-config.js"; -import type { MessageKey } from "../i18n/strings.js"; - -type TranslateFn = (key: MessageKey, vars?: Record) => string; - -export function registerSkillsCommand(program: Command, tr: TranslateFn): void { - const skills = program.command("skills").description(tr("cmdSkills")); - - skills - .command("list") - .description(tr("cmdSkillsList")) - .action(async () => { - const cwd = process.cwd(); - const paths = await loadSkillPackPathsFromConfig(cwd); - if (paths.length === 0) { - process.stdout.write(`${tr("skillsListEmpty")}\n`); - return; - } - process.stdout.write(`${tr("skillsListHeader")}\n`); - const known = knownChatToolNames({ - readOnly: false, - includeCodebaseSearch: true, - includeSemanticSearch: true, - }); - for (const p of paths) { - try { - const pack = await loadSkillPack(cwd, p); - const m = pack.manifest; - const allowed = m.tools_allow.filter((t) => known.has(t)); - const unknown = m.tools_allow.length - allowed.length; - process.stdout.write(`- ${m.id}@${m.version} ${m.name}\n`); - process.stdout.write(` path: ${p}\n`); - process.stdout.write(` tools: ${allowed.join(", ")}${unknown > 0 ? ` (+${unknown} not in default session)` : ""}\n`); - } catch (e) { - process.stderr.write(`${tr("skillsListError", { path: p, detail: String(e) })}\n`); - } - } - }); -} diff --git a/packages/cli/src/tools/embeddings-api.ts b/packages/cli/src/tools/embeddings-api.ts deleted file mode 100644 index 8f988cf..0000000 --- a/packages/cli/src/tools/embeddings-api.ts +++ /dev/null @@ -1,69 +0,0 @@ -export type EmbeddingClient = { - baseURL: string; - apiKey: string; - model: string; -}; - -type EmbeddingsResponse = { - data: Array<{ embedding: number[]; index: number }>; -}; - -export async function fetchEmbeddings( - client: EmbeddingClient, - inputs: string[], - signal?: AbortSignal, -): Promise { - if (inputs.length === 0) return []; - const url = `${client.baseURL.replace(/\/$/, "")}/embeddings`; - const res = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${client.apiKey}`, - }, - body: JSON.stringify({ model: client.model, input: inputs }), - signal, - }); - const text = await res.text(); - if (!res.ok) { - throw new Error(`embeddings HTTP ${res.status}: ${text.slice(0, 800)}`); - } - let j: EmbeddingsResponse; - try { - j = JSON.parse(text) as EmbeddingsResponse; - } catch (e) { - const err = new Error(`embeddings: invalid JSON: ${String(e)}`); - if (e instanceof Error) err.cause = e; - throw err; - } - const rows = j.data ?? []; - const out: number[][] = Array.from({ length: inputs.length }, () => []); - const indexed = rows.every((d) => typeof d.index === "number"); - if (!indexed && rows.length === inputs.length) { - return rows.map((d) => d.embedding); - } - for (const d of rows) { - if (typeof d.index === "number" && Array.isArray(d.embedding)) { - out[d.index] = d.embedding; - } - } - return out; -} - -export function cosineSimilarity(a: number[], b: number[]): number { - if (a.length !== b.length || a.length === 0) return 0; - let dot = 0; - let na = 0; - let nb = 0; - for (let i = 0; i < a.length; i++) { - dot += a[i] * b[i]; - na += a[i] * a[i]; - nb += b[i] * b[i]; - } - const d = Math.sqrt(na) * Math.sqrt(nb); - return d < 1e-12 ? 0 : dot / d; -} - -export async function pingEmbeddings(client: EmbeddingClient, signal?: AbortSignal): Promise { - await fetchEmbeddings(client, ["inaricode-doctor"], signal); -} diff --git a/packages/cli/src/tools/engine-run.ts b/packages/cli/src/tools/engine-run.ts deleted file mode 100644 index 37599d5..0000000 --- a/packages/cli/src/tools/engine-run.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { z } from "zod"; -import { engineRequest, type EngineEnvelope } from "../engine/client.js"; -import { assertShellAllowed, type ResolvedShellPolicy } from "../policy/shell.js"; -import { MUTATING_TOOL_NAMES } from "../llm/inari-tools.js"; -import { sidecarRpc } from "../sidecar/client.js"; -import type { EmbeddingClient } from "./embeddings-api.js"; -import { redactToolOutput } from "./redact.js"; -import { runSemanticCodebaseSearch } from "./semantic-search.js"; -import { extractSymbolOutline } from "./symbol-outline.js"; - -const readFileSchema = z.object({ - path: z.string().min(1), - start_line: z.number().int().positive().optional(), - end_line: z.number().int().positive().optional(), -}); - -const writeFileSchema = z.object({ - path: z.string().min(1), - content: z.string(), -}); - -const listDirSchema = z.object({ - path: z.string().optional(), - max_entries: z.number().int().positive().optional(), -}); - -const grepSchema = z.object({ - pattern: z.string().min(1), - max_matches: z.number().int().positive().optional(), - path_prefix: z.string().optional(), -}); - -const searchReplaceSchema = z.object({ - path: z.string().min(1), - old_string: z.string(), - new_string: z.string(), - replace_all: z.boolean().optional(), -}); - -const runCmdSchema = z.object({ - command: z.string().min(1), - cwd: z.string().optional(), - timeout_ms: z.number().int().positive().optional(), -}); - -const applyPatchSchema = z.object({ - path: z.string().min(1), - unified_diff: z.string().min(1).max(512 * 1024), -}); - -const codebaseSearchSchema = z.object({ - query: z.string().min(1), - max_results: z.number().int().positive().max(50).optional(), - max_files: z.number().int().positive().max(10_000).optional(), -}); - -const semanticCodebaseSearchSchema = z.object({ - query: z.string().min(1), - max_results: z.number().int().positive().max(50).optional(), - max_files: z.number().int().positive().max(5000).optional(), - refresh_index: z.boolean().optional(), -}); - -const symbolOutlineSchema = z.object({ - path: z.string().min(1), -}); - -export type ConfirmFn = (detail: { title: string; body: string }) => Promise; - -let requestSeq = 0; -function nextId(): string { - requestSeq += 1; - return `t-${Date.now()}-${requestSeq}`; -} - -export async function runEngineTool(params: { - workspaceRoot: string; - name: string; - input: unknown; - confirm: ConfirmFn; - skipConfirm: boolean; - readOnly: boolean; - shellPolicy: ResolvedShellPolicy; - sidecarArgv: string[] | null; - embeddingClient: EmbeddingClient | null; - signal?: AbortSignal; -}): Promise { - return redactToolOutput(await runEngineToolInner(params)); -} - -async function runEngineToolInner(params: { - workspaceRoot: string; - name: string; - input: unknown; - confirm: ConfirmFn; - skipConfirm: boolean; - readOnly: boolean; - shellPolicy: ResolvedShellPolicy; - sidecarArgv: string[] | null; - embeddingClient: EmbeddingClient | null; - signal?: AbortSignal; -}): Promise { - const { - workspaceRoot, - name, - input, - confirm, - skipConfirm, - readOnly, - shellPolicy, - sidecarArgv, - embeddingClient, - signal, - } = params; - - if (readOnly && MUTATING_TOOL_NAMES.has(name)) { - return `Error: tool "${name}" is disabled in read-only mode.`; - } - - const exec = async (cmd: string, payload: Record) => { - const env: EngineEnvelope = { - id: nextId(), - cmd, - workspace: workspaceRoot, - payload, - }; - const reply = await engineRequest(env); - if (reply.ok) { - return JSON.stringify(reply.result, null, 2); - } - return `Error: ${reply.error}`; - }; - - if (name === "read_file") { - const p = readFileSchema.parse(input); - const payload: Record = { path: p.path }; - if (p.start_line !== undefined) payload.start_line = p.start_line; - if (p.end_line !== undefined) payload.end_line = p.end_line; - return exec("read_file", payload); - } - if (name === "list_dir") { - const p = listDirSchema.parse(input); - const payload: Record = { path: p.path ?? "." }; - if (p.max_entries !== undefined) payload.max_entries = p.max_entries; - return exec("list_dir", payload); - } - if (name === "grep") { - const p = grepSchema.parse(input); - const payload: Record = { pattern: p.pattern }; - if (p.max_matches !== undefined) payload.max_matches = p.max_matches; - if (p.path_prefix !== undefined) payload.path_prefix = p.path_prefix; - return exec("grep", payload); - } - if (name === "symbol_outline") { - const p = symbolOutlineSchema.parse(input); - const raw = await exec("read_file", { path: p.path }); - try { - const data = JSON.parse(raw) as { content?: string }; - if (typeof data.content !== "string") { - return "Error: symbol_outline: missing file content (file missing or too large?)."; - } - const out = extractSymbolOutline(p.path, data.content); - return JSON.stringify(out, null, 2); - } catch (e) { - return `Error: symbol_outline: ${String(e)}`; - } - } - if (name === "write_file") { - const p = writeFileSchema.parse(input); - if ( - !skipConfirm && - !(await confirm({ - title: "write_file", - body: `${p.path}\n---\n${truncate(p.content, 4000)}`, - })) - ) { - return "User declined write_file."; - } - return exec("write_file", { path: p.path, content: p.content }); - } - if (name === "search_replace") { - const p = searchReplaceSchema.parse(input); - if ( - !skipConfirm && - !(await confirm({ - title: "search_replace", - body: `${p.path}\nold:\n${truncate(p.old_string, 2000)}\nnew:\n${truncate(p.new_string, 2000)}`, - })) - ) { - return "User declined search_replace."; - } - const payload: Record = { - path: p.path, - old_string: p.old_string, - new_string: p.new_string, - }; - if (p.replace_all !== undefined) payload.replace_all = p.replace_all; - return exec("search_replace", payload); - } - if (name === "apply_patch") { - const p = applyPatchSchema.parse(input); - if ( - !skipConfirm && - !(await confirm({ - title: "apply_patch", - body: `${p.path}\n---\n(unified diff preview)\n${truncate(p.unified_diff, 4000)}`, - })) - ) { - return "User declined apply_patch."; - } - return exec("apply_patch", { path: p.path, unified_diff: p.unified_diff }); - } - if (name === "run_terminal_cmd") { - const p = runCmdSchema.parse(input); - assertShellAllowed(p.command, shellPolicy); - if ( - !skipConfirm && - !(await confirm({ - title: "run_terminal_cmd", - body: `cwd: ${p.cwd ?? "."}\n${p.command}`, - })) - ) { - return "User declined run_terminal_cmd."; - } - return exec("run_cmd", { - command: p.command, - cwd: p.cwd ?? ".", - timeout_ms: p.timeout_ms, - }); - } - if (name === "codebase_search") { - const p = codebaseSearchSchema.parse(input); - if (!sidecarArgv) { - return ( - "Error: codebase_search needs the Python sidecar. Set sidecar: { enabled: true } in inaricode config, " + - "install pathspec (`pip install -r packages/sidecar/requirements.txt`), and ensure `python3` can run " + - "packages/sidecar/inari_sidecar.py (or set sidecar.command / INARI_SIDECAR_CMD)." - ); - } - try { - const result = await sidecarRpc(sidecarArgv, { - id: nextId(), - method: "codebase_search", - params: { - workspace: workspaceRoot, - query: p.query, - max_results: p.max_results, - max_files: p.max_files, - }, - }); - return JSON.stringify(result, null, 2); - } catch (e) { - return `Error: codebase_search sidecar failed: ${String(e)}`; - } - } - if (name === "semantic_codebase_search") { - const p = semanticCodebaseSearchSchema.parse(input); - if (!embeddingClient) { - return ( - "Error: semantic_codebase_search needs embeddings: { enabled: true } in config. " + - "For provider anthropic, set OPENAI_API_KEY (or embeddings.apiKey) — default baseURL is https://api.openai.com/v1. " + - "For OpenAI-compatible chat providers, embeddings reuse chat baseURL/apiKey unless overridden." - ); - } - try { - const result = await runSemanticCodebaseSearch({ - workspaceRoot, - client: embeddingClient, - query: p.query, - maxResults: p.max_results ?? 15, - maxFiles: p.max_files ?? 500, - refreshIndex: p.refresh_index ?? false, - signal, - }); - return JSON.stringify(result, null, 2); - } catch (e) { - return `Error: semantic_codebase_search failed: ${String(e)}`; - } - } - - return `Unknown tool: ${name}`; -} - -function truncate(s: string, max: number): string { - if (s.length <= max) return s; - return `${s.slice(0, max)}\n… [truncated ${s.length - max} chars]`; -} diff --git a/packages/cli/src/tools/redact.ts b/packages/cli/src/tools/redact.ts deleted file mode 100644 index 3ff1570..0000000 --- a/packages/cli/src/tools/redact.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Best-effort redaction of secrets from tool output before it reaches the model or terminal. - */ - -const AWS_KEY = /\bAKIA[0-9A-Z]{16}\b/g; -const OPENAI_SK = /\bsk-proj-[A-Za-z0-9_-]+\b/g; -const OPENAI_LONG = /\bsk-[A-Za-z0-9]{48,}\b/g; -const ANTHROPIC_SK = /\bsk-ant-[A-Za-z0-9\-_]{40,}\b/gi; -const ASSIGN_SECRET = - /\b(api[_-]?key|apikey|client_secret|secret|password|token|bearer)\b\s*[:=]\s*[^\s"'`]{8,}/gi; - -export function redactToolOutput(text: string): string { - let s = text; - s = s.replace(AWS_KEY, "[REDACTED]"); - s = s.replace(OPENAI_SK, "[REDACTED]"); - s = s.replace(OPENAI_LONG, "[REDACTED]"); - s = s.replace(ANTHROPIC_SK, "[REDACTED]"); - s = s.replace(ASSIGN_SECRET, (m) => m.replace(/[:=]\s*[^\s"'`]{8,}/, ":= [REDACTED]")); - return s; -} diff --git a/packages/cli/src/tools/semantic-search.ts b/packages/cli/src/tools/semantic-search.ts deleted file mode 100644 index 6e1c350..0000000 --- a/packages/cli/src/tools/semantic-search.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { globby } from "globby"; -import { engineRequest } from "../engine/client.js"; -import { cosineSimilarity, fetchEmbeddings, type EmbeddingClient } from "./embeddings-api.js"; - -const GLOB_PATTERNS = [ - "**/*.{ts,tsx,mts,cts,js,cjs,mjs,jsx,py,rs,go,java,kt,cs,php,rb,md,json,yml,yaml,toml,c,h,cpp,hpp}", -]; -const EXTRA_IGNORE = [ - "**/node_modules/**", - "**/.git/**", - "**/dist/**", - "**/target/**", - "**/.yarn/**", - "**/__pycache__/**", - "**/.next/**", - "**/build/**", -]; - -const CHUNK_CHARS = 3200; -const CHUNK_OVERLAP = 400; - -type CacheChunk = { start: number; text: string; embedding: number[] }; -type CacheEntry = { mtimeMs: number; size: number; chunks: CacheChunk[] }; -type CacheFile = { - v: 1; - model: string; - baseURL: string; - entries: Record; -}; - -async function readInariIgnoreLines(root: string): Promise { - try { - const raw = await readFile(join(root, ".inariignore"), "utf8"); - return raw - .split("\n") - .map((l) => l.trim()) - .filter((l) => l.length > 0 && !l.startsWith("#")); - } catch { - return []; - } -} - -function chunkText(text: string): { start: number; text: string }[] { - const out: { start: number; text: string }[] = []; - if (text.length <= CHUNK_CHARS) { - return [{ start: 0, text }]; - } - let start = 0; - while (start < text.length) { - const end = Math.min(start + CHUNK_CHARS, text.length); - out.push({ start, text: text.slice(start, end) }); - if (end >= text.length) break; - start = end - CHUNK_OVERLAP; - if (start < 0) start = 0; - } - return out; -} - -async function engineReadFile(workspaceRoot: string, relPath: string): Promise { - const reply = await engineRequest({ - id: `semantic-read-${relPath}`, - cmd: "read_file", - workspace: workspaceRoot, - payload: { path: relPath }, - }); - if (!reply.ok) return null; - const r = reply.result as { content?: string }; - return typeof r.content === "string" ? r.content : null; -} - -function cachePath(workspaceRoot: string): string { - return join(workspaceRoot, ".inaricode", "semantic-cache-v1.json"); -} - -async function loadCache(p: string, client: EmbeddingClient): Promise { - try { - const raw = await readFile(p, "utf8"); - const j = JSON.parse(raw) as CacheFile; - if ( - j.v === 1 && - j.model === client.model && - j.baseURL === client.baseURL && - j.entries && - typeof j.entries === "object" - ) { - return j; - } - } catch { - /* fresh */ - } - return { v: 1, model: client.model, baseURL: client.baseURL, entries: {} }; -} - -async function saveCache(p: string, c: CacheFile): Promise { - await mkdir(dirname(p), { recursive: true }); - await writeFile(p, JSON.stringify(c), "utf8"); -} - -async function discoverFiles(workspaceRoot: string, maxFiles: number): Promise { - const ign = await readInariIgnoreLines(workspaceRoot); - const files = await globby(GLOB_PATTERNS, { - cwd: workspaceRoot, - gitignore: true, - ignore: [...EXTRA_IGNORE, ...ign], - onlyFiles: true, - unique: true, - dot: false, - }); - return files.slice(0, maxFiles); -} - -async function embedInBatches( - client: EmbeddingClient, - texts: string[], - signal: AbortSignal | undefined, - batchSize: number, -): Promise { - const out: number[][] = []; - for (let i = 0; i < texts.length; i += batchSize) { - const batch = texts.slice(i, i + batchSize); - const part = await fetchEmbeddings(client, batch, signal); - out.push(...part); - } - return out; -} - -export async function runSemanticCodebaseSearch(params: { - workspaceRoot: string; - client: EmbeddingClient; - query: string; - maxResults: number; - maxFiles: number; - refreshIndex: boolean; - signal?: AbortSignal; -}): Promise> { - const { workspaceRoot, client, query, maxResults, maxFiles, refreshIndex, signal } = params; - const cp = cachePath(workspaceRoot); - const cache = refreshIndex - ? { v: 1 as const, model: client.model, baseURL: client.baseURL, entries: {} } - : await loadCache(cp, client); - - const files = await discoverFiles(workspaceRoot, maxFiles); - const toEmbed: { path: string; chunks: { start: number; text: string }[] }[] = []; - - for (const rel of files) { - let st; - try { - st = await stat(join(workspaceRoot, rel)); - } catch { - continue; - } - if (!st.isFile()) continue; - const prev = cache.entries[rel]; - if (!refreshIndex && prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size && prev.chunks.length > 0) { - continue; - } - const content = await engineReadFile(workspaceRoot, rel); - if (content === null) continue; - const parts = chunkText(content); - toEmbed.push({ path: rel, chunks: parts }); - } - - if (toEmbed.length > 0) { - const flatTexts: string[] = []; - const meta: { path: string; start: number }[] = []; - for (const item of toEmbed) { - for (const c of item.chunks) { - meta.push({ path: item.path, start: c.start }); - flatTexts.push(c.text); - } - } - const vectors = await embedInBatches(client, flatTexts, signal, 24); - const byPath = new Map(); - for (let i = 0; i < meta.length; i++) { - const m = meta[i]; - const emb = vectors[i]; - if (!emb || emb.length === 0) continue; - const arr = byPath.get(m.path) ?? []; - arr.push({ start: m.start, text: flatTexts[i] ?? "", embedding: emb }); - byPath.set(m.path, arr); - } - for (const item of toEmbed) { - let st; - try { - st = await stat(join(workspaceRoot, item.path)); - } catch { - continue; - } - const chunks = byPath.get(item.path) ?? []; - cache.entries[item.path] = { mtimeMs: st.mtimeMs, size: st.size, chunks }; - } - } - - const present = new Set(files); - for (const k of Object.keys(cache.entries)) { - if (!present.has(k)) delete cache.entries[k]; - } - - await saveCache(cp, cache); - - const [qVec] = await fetchEmbeddings(client, [query], signal); - if (!qVec || qVec.length === 0) { - return { error: "empty query embedding", query, files_indexed: files.length }; - } - - type Hit = { path: string; score: number; start: number; snippet: string }; - const hits: Hit[] = []; - for (const [path, ent] of Object.entries(cache.entries)) { - for (const ch of ent.chunks) { - const score = cosineSimilarity(qVec, ch.embedding); - const sn = ch.text.replace(/\s+/g, " ").trim().slice(0, 280); - hits.push({ path, score, start: ch.start, snippet: sn + (ch.text.length > 280 ? " …" : "") }); - } - } - hits.sort((a, b) => b.score - a.score); - const top = hits.slice(0, maxResults); - - return { - query, - model: client.model, - files_globbed: files.length, - cache_path: ".inaricode/semantic-cache-v1.json", - results: top.map((h) => ({ - path: h.path, - score: Math.round(h.score * 10000) / 10000, - char_offset: h.start, - snippet: h.snippet, - })), - }; -} diff --git a/packages/cli/src/tools/symbol-outline-ast.ts b/packages/cli/src/tools/symbol-outline-ast.ts deleted file mode 100644 index 15007a8..0000000 --- a/packages/cli/src/tools/symbol-outline-ast.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { createRequire } from "node:module"; -import type { OutlineSymbol } from "./symbol-outline.js"; - -type SyntaxNode = { - type: string; - text: string; - startPosition: { row: number }; - childForFieldName(name: string): SyntaxNode | null; - descendantsOfType(types: string | string[]): SyntaxNode[]; -}; - -/** - * tree-sitter outline for TypeScript / JavaScript. Returns null if native addons - * fail to load or the file is not a supported grammar. - */ -export function extractSymbolOutlineAst(filePath: string, content: string): OutlineSymbol[] | null { - const ext = filePath.includes(".") ? (filePath.split(".").pop() ?? "").toLowerCase() : ""; - const useTsx = ext === "tsx" || ext === "jsx"; - const useTs = ext === "ts" || ext === "mts" || ext === "cts"; - const useJs = ext === "js" || ext === "mjs" || ext === "cjs"; - if (!useTsx && !useTs && !useJs) { - return null; - } - - try { - const require = createRequire(import.meta.url); - const Parser = require("tree-sitter") as new () => ParserInstance; - const tsMod = require("tree-sitter-typescript") as { typescript: unknown; tsx: unknown }; - const jsLang = require("tree-sitter-javascript") as unknown; - - type ParserInstance = { - setLanguage(lang: unknown): void; - parse(input: string): { rootNode: SyntaxNode }; - }; - const parser = new Parser(); - if (useTsx) { - parser.setLanguage(tsMod.tsx); - } else if (useTs) { - parser.setLanguage(tsMod.typescript); - } else { - parser.setLanguage(jsLang); - } - - const tree = parser.parse(content); - const root = tree.rootNode; - const types = [ - "function_declaration", - "generator_function_declaration", - "class_declaration", - "interface_declaration", - "type_alias_declaration", - "enum_declaration", - "method_definition", - "public_field_definition", - "variable_declarator", - ]; - const nodes = root.descendantsOfType(types); - const symbols: OutlineSymbol[] = []; - const seen = new Set(); - - const kindFor = (t: string): string => { - if (t === "class_declaration") return "class"; - if (t === "interface_declaration") return "interface"; - if (t === "type_alias_declaration") return "type"; - if (t === "enum_declaration") return "enum"; - if (t === "method_definition" || t === "public_field_definition") return "member"; - if (t === "variable_declarator") return "variable"; - return "function"; - }; - - for (const node of nodes) { - const nameNode = node.childForFieldName("name"); - if (node.type === "variable_declarator" && nameNode && nameNode.type !== "identifier") { - continue; - } - const name = - nameNode && - (nameNode.type === "property_identifier" || - nameNode.type === "identifier" || - nameNode.type === "type_identifier") - ? nameNode.text - : null; - if (!name) continue; - const line = node.startPosition.row + 1; - const key = `${line}:${name}`; - if (seen.has(key)) continue; - seen.add(key); - symbols.push({ line, kind: kindFor(node.type), name }); - } - - symbols.sort((a, b) => a.line - b.line || a.name.localeCompare(b.name)); - return symbols; - } catch { - return null; - } -} diff --git a/packages/cli/src/tools/symbol-outline.ts b/packages/cli/src/tools/symbol-outline.ts deleted file mode 100644 index 4892a7c..0000000 --- a/packages/cli/src/tools/symbol-outline.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { extractSymbolOutlineAst } from "./symbol-outline-ast.js"; - -export type OutlineSymbol = { - line: number; - kind: string; - name: string; -}; - -/** - * File outline for `symbol_outline`: **tree-sitter** for TypeScript / JavaScript (classes, - * functions, interfaces, methods, …) when native grammars load; **regex** fallback for the - * same extensions on failure and for **Python / Rust / Go** (and heuristic TS/JS lines). - */ -export function extractSymbolOutline(filePath: string, content: string): { symbols: OutlineSymbol[] } { - const ast = extractSymbolOutlineAst(filePath, content); - if (ast !== null && ast.length > 0) { - return { symbols: ast }; - } - return extractSymbolOutlineRegex(filePath, content); -} - -function extractSymbolOutlineRegex(filePath: string, content: string): { symbols: OutlineSymbol[] } { - const lines = content.split("\n"); - const ext = filePath.includes(".") ? (filePath.split(".").pop() ?? "").toLowerCase() : ""; - const symbols: OutlineSymbol[] = []; - - const push = (line: number, kind: string, name: string) => { - if (name && !symbols.some((s) => s.line === line && s.name === name)) { - symbols.push({ line, kind, name }); - } - }; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] ?? ""; - const n = i + 1; - - if (["ts", "tsx", "mts", "cts", "js", "cjs", "mjs", "jsx"].includes(ext)) { - let m = line.match(/^\s*export\s+default\s+function\s+([A-Za-z0-9_$]+)/); - if (m) { - push(n, "function", m[1]); - continue; - } - m = line.match(/^\s*export\s+(?:async\s+)?function\s+([A-Za-z0-9_$]+)/); - if (m) { - push(n, "function", m[1]); - continue; - } - m = line.match(/^\s*export\s+(?:async\s+)?(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=/); - if (m) { - push(n, "variable", m[1]); - continue; - } - m = line.match(/^\s*export\s+(?:abstract\s+)?class\s+([A-Za-z0-9_$]+)/); - if (m) { - push(n, "class", m[1]); - continue; - } - m = line.match(/^\s*export\s+(?:type|interface)\s+([A-Za-z0-9_$]+)/); - if (m) { - push(n, "type", m[1]); - continue; - } - m = line.match(/^\s*(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*\(/); - if (m) { - push(n, "function", m[1]); - continue; - } - m = line.match(/^\s*class\s+([A-Za-z0-9_$]+)/); - if (m) { - push(n, "class", m[1]); - continue; - } - } - - if (ext === "py") { - let m = line.match(/^\s*(?:async\s+)?def\s+([A-Za-z0-9_]+)\s*\(/); - if (m) { - push(n, "function", m[1]); - continue; - } - m = line.match(/^\s*class\s+([A-Za-z0-9_]+)\s*(?:\(|:)/); - if (m) { - push(n, "class", m[1]); - continue; - } - } - - if (ext === "rs") { - let m = line.match(/^\s*pub\s+(?:async\s+)?fn\s+([A-Za-z0-9_]+)/); - if (m) { - push(n, "function", m[1]); - continue; - } - m = line.match(/^\s*(?:pub\s+)?(?:struct|enum|trait)\s+([A-Za-z0-9_]+)/); - if (m) { - push(n, "item", m[1]); - continue; - } - m = line.match(/^\s*fn\s+([A-Za-z0-9_]+)\s*\(/); - if (m) { - push(n, "function", m[1]); - continue; - } - } - - if (ext === "go") { - const m = line.match(/^\s*func\s+(?:\([^)]*\)\s+)?([A-Za-z0-9_]+)\s*\(/); - if (m) { - push(n, "function", m[1]); - } - } - } - - symbols.sort((a, b) => a.line - b.line); - return { symbols }; -} diff --git a/packages/cli/src/ui/chat-chrome.ts b/packages/cli/src/ui/chat-chrome.ts deleted file mode 100644 index 881f276..0000000 --- a/packages/cli/src/ui/chat-chrome.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { homedir } from "node:os"; -import type { Locale } from "../i18n/locale.js"; -import { tr } from "../i18n/strings.js"; - -export type ChatThemeId = "default" | "soft" | "high_contrast"; - -export type ChatSessionContext = { - locale: Locale; - /** Full line from `cliVersionLine()` (semver · patch · flower). */ - version: string; - provider: string; - model: string; - workspaceRoot: string; - sessionPath: string | null; - readOnly: boolean; - streaming: boolean; - /** No ANSI / minimal Ink styling (also INARI_PLAIN=1). */ - plain: boolean; - /** Git branch under workspace, if any. */ - gitBranch: string | null; - /** REPL palette from config (`chatTheme`). */ - chatTheme: ChatThemeId; -}; - -function ansiBase(): boolean { - if (process.env.NO_COLOR != null && process.env.NO_COLOR !== "") return false; - if (process.env.FORCE_COLOR === "0") return false; - return process.stdout.isTTY === true; -} - -export function useChatAnsi(plain: boolean): boolean { - if (plain) return false; - return ansiBase(); -} - -type Palette = { - reset: string; - bold: string; - dim: string; - fgMuted: string; - fgLine: string; - fgYou: string; - fgAssistant: string; -}; - -const PALETTES: Record = { - default: { - reset: "\x1b[0m", - bold: "\x1b[1m", - dim: "\x1b[2m", - fgMuted: "\x1b[38;5;245m", - fgLine: "\x1b[38;5;238m", - fgYou: "\x1b[38;5;73m", - fgAssistant: "\x1b[38;5;252m", - }, - soft: { - reset: "\x1b[0m", - bold: "\x1b[1m", - dim: "\x1b[2m", - fgMuted: "\x1b[38;5;244m", - fgLine: "\x1b[38;5;240m", - fgYou: "\x1b[38;5;109m", - fgAssistant: "\x1b[38;5;250m", - }, - high_contrast: { - reset: "\x1b[0m", - bold: "\x1b[1m", - dim: "\x1b[2m", - fgMuted: "\x1b[90m", - fgLine: "\x1b[37m", - fgYou: "\x1b[96m", - fgAssistant: "\x1b[97m", - }, -}; - -function palette(theme: ChatThemeId, use: boolean): Palette { - if (!use) { - return { - reset: "", - bold: "", - dim: "", - fgMuted: "", - fgLine: "", - fgYou: "", - fgAssistant: "", - }; - } - return PALETTES[theme] ?? PALETTES.default; -} - -/** Tilde-abbreviate home directory for display. */ -export function shortenPath(absPath: string): string { - const home = homedir(); - if (!home) return absPath; - const norm = absPath.replace(/\\/g, "/"); - const h = home.replace(/\\/g, "/"); - if (norm === h) return "~"; - if (norm.startsWith(h.endsWith("/") ? h : `${h}/`)) { - return `~${norm.slice(h.length)}`; - } - return absPath; -} - -export function replPrompt(plain: boolean, theme: ChatThemeId = "default"): string { - const c = palette(theme, useChatAnsi(plain)); - return `\n${c.fgMuted}›${c.reset} `; -} - -export function replTurnSeparator(plain: boolean, theme: ChatThemeId = "default"): string { - const c = palette(theme, useChatAnsi(plain)); - const w = Math.min(44, Math.max(32, (process.stdout.columns ?? 56) - 4)); - return `${c.dim}${c.fgLine}${"─".repeat(w)}${c.reset}\n`; -} - -export function replAssistantLead(plain: boolean, theme: ChatThemeId = "default"): string { - const c = palette(theme, useChatAnsi(plain)); - return `\n${c.dim}${c.fgAssistant}assistant${c.reset}\n`; -} - -export function replUserBlock(locale: Locale, line: string, plain: boolean, theme: ChatThemeId = "default"): string { - const c = palette(theme, useChatAnsi(plain)); - const you = tr(locale, "chatReplYou"); - return `\n${c.fgYou}${c.bold}${you}${c.reset} ${c.dim}›${c.reset} ${line}\n`; -} - -/** Full welcome block for readline chat (no mascot ASCII). */ -export function formatReplSessionWelcome(ctx: ChatSessionContext): string { - const c = palette(ctx.chatTheme, useChatAnsi(ctx.plain)); - const L = ctx.locale; - const pathDisp = shortenPath(ctx.workspaceRoot); - const badges: string[] = []; - if (ctx.readOnly) badges.push(tr(L, "chatBadgeReadOnly")); - badges.push(ctx.streaming ? tr(L, "chatBadgeStream") : tr(L, "chatBadgeBuffer")); - - const lineModel = tr(L, "chatChromeLineModel", { provider: ctx.provider, model: ctx.model }); - const lineWs = tr(L, "chatChromeLineWorkspace", { path: pathDisp }); - let out = ""; - out += `${c.bold}${c.fgYou}InariCode${c.reset} ${c.dim}${ctx.version}${c.reset} ${c.fgMuted}·${c.reset} ${c.dim}${tr(L, "chatChromeSubtitle")}${c.reset}\n`; - out += `${c.dim}${lineModel}${c.reset}\n`; - out += `${c.dim}${lineWs}${c.reset}\n`; - if (ctx.gitBranch) { - out += `${c.dim}${tr(L, "chatChromeBranch", { branch: ctx.gitBranch })}${c.reset}\n`; - } - if (ctx.sessionPath) { - out += `${c.dim}${tr(L, "chatChromeSession", { path: shortenPath(ctx.sessionPath) })}${c.reset}\n`; - } - out += `${c.dim}${badges.join(" · ")}${c.reset}\n`; - out += `\n${c.dim}${tr(L, "chatHintShort")}${c.reset}\n`; - return out; -} - -export type TuiChromeLines = { - title: string; - subtitle: string; - modelLine: string; - workspaceLine: string; - branchLine: string | null; - sessionLine: string | null; - badges: string; - hint: string; -}; - -/** Ink accent for busy / prompts (matches `chatTheme` when not plain). */ -export function tuiAccentColor(theme: ChatThemeId, plain: boolean): "cyan" | "blue" | "magenta" | "gray" { - if (plain) return "gray"; - if (theme === "high_contrast") return "magenta"; - if (theme === "soft") return "blue"; - return "cyan"; -} - -export function buildTuiChromeLines(ctx: ChatSessionContext): TuiChromeLines { - const L = ctx.locale; - const pathDisp = shortenPath(ctx.workspaceRoot); - const badgeParts: string[] = []; - if (ctx.readOnly) badgeParts.push(tr(L, "chatBadgeReadOnly")); - badgeParts.push(ctx.streaming ? tr(L, "chatBadgeStream") : tr(L, "chatBadgeBuffer")); - return { - title: `InariCode ${ctx.version}`, - subtitle: tr(L, "chatChromeSubtitle"), - modelLine: tr(L, "chatChromeLineModel", { provider: ctx.provider, model: ctx.model }), - workspaceLine: tr(L, "chatChromeLineWorkspace", { path: pathDisp }), - branchLine: ctx.gitBranch ? tr(L, "chatChromeBranch", { branch: ctx.gitBranch }) : null, - sessionLine: ctx.sessionPath ? tr(L, "chatChromeSession", { path: shortenPath(ctx.sessionPath) }) : null, - badges: badgeParts.join(" · "), - hint: tr(L, "chatHintShort"), - }; -} diff --git a/packages/cli/src/ui/chat-repl.ts b/packages/cli/src/ui/chat-repl.ts deleted file mode 100644 index b7ace83..0000000 --- a/packages/cli/src/ui/chat-repl.ts +++ /dev/null @@ -1,192 +0,0 @@ -import * as readline from "node:readline/promises"; -import { stdin as input, stdout as output } from "node:process"; -import { resolve } from "node:path"; -import { loadConfig } from "../config.js"; -import { createLlmProvider } from "../llm/create-provider.js"; -import { applySkillToolAllowlist, chatToolDefinitions, knownChatToolNames } from "../llm/inari-tools.js"; -import { resolveSkillsContext } from "../skills/resolve-context.js"; -import type { AgentHistoryItem } from "../llm/types.js"; -import { createChatSystemPrompt, runAgentTurn } from "../agent/loop.js"; -import type { ConfirmFn } from "../tools/engine-run.js"; -import { loadSessionFile, saveSessionFile } from "../session/file-session.js"; -import { cliVersionLine } from "../pkg-meta.js"; -import { tr } from "../i18n/strings.js"; -import type { Locale } from "../i18n/locale.js"; -import { isAffirmativeInput, isExitCommand } from "../i18n/prompts.js"; -import { - formatReplSessionWelcome, - replAssistantLead, - replPrompt, - replTurnSeparator, - replUserBlock, - useChatAnsi, -} from "./chat-chrome.js"; -import { handleChatSlashInput } from "./chat-slash.js"; -import { getWorkspaceGitBranch } from "./git-branch.js"; -import { resolveWorkspaceRoot } from "../workspace-root.js"; - -export { resolveWorkspaceRoot }; - -function createConfirm(rl: readline.Interface, locale: Locale, plain: boolean): ConfirmFn { - const ansi = useChatAnsi(plain); - const dim = ansi ? "\x1b[2m" : ""; - const y = ansi ? "\x1b[33m" : ""; - const reset = ansi ? "\x1b[0m" : ""; - return async ({ title, body }) => { - output.write( - `\n${y}?${reset} ${dim}${title}${reset}\n${dim}${body.split("\n").join("\n")}${reset}\n${tr(locale, "confirmPrompt")}`, - ); - const ans = await rl.question(""); - return isAffirmativeInput(ans, locale); - }; -} - -export async function runChatRepl(options: { - cwd: string; - workspaceRoot: string; - skipConfirm: boolean; - sessionFile?: string; - noStream: boolean; - readOnlyCli: boolean; - plainCli: boolean; - signal?: AbortSignal; - /** Override config / env for this session (same as `inari chat --provider` / `--model`). */ - providerCli?: string; - modelCli?: string; -}): Promise { - const plain = options.plainCli || process.env.INARI_PLAIN === "1"; - const cfg = await loadConfig(options.cwd, { - provider: options.providerCli, - model: options.modelCli, - }); - const provider = createLlmProvider(cfg); - const readOnly = cfg.readOnly || options.readOnlyCli; - const useStream = cfg.streaming && !options.noStream; - const sidecarArgv = cfg.sidecar.argv; - const includeCodebaseSearch = sidecarArgv !== null; - const includeSemanticSearch = cfg.embeddings.client !== null; - const known = knownChatToolNames({ - readOnly, - includeCodebaseSearch, - includeSemanticSearch, - }); - const skillsCtx = await resolveSkillsContext(options.cwd, cfg.skillPackPaths, known); - let tools = chatToolDefinitions(readOnly, includeCodebaseSearch, includeSemanticSearch); - tools = applySkillToolAllowlist(tools, skillsCtx.toolAllowlist); - const system = createChatSystemPrompt(options.workspaceRoot, skillsCtx.systemPromptAppendix); - const slashHelpExtra = - skillsCtx.slashHints.length > 0 - ? `\nSkill hints:\n${skillsCtx.slashHints.map((l) => ` · ${l}`).join("\n")}\n` - : undefined; - - let history: AgentHistoryItem[] = options.sessionFile - ? await loadSessionFile(resolve(options.cwd, options.sessionFile)) - : []; - - const rl = readline.createInterface({ input, output, terminal: true }); - const loc = cfg.locale; - const confirm = createConfirm(rl, loc, plain); - - const sessionPath = options.sessionFile ? resolve(options.cwd, options.sessionFile) : null; - const gitBranch = await getWorkspaceGitBranch(options.workspaceRoot); - - output.write( - formatReplSessionWelcome({ - locale: loc, - version: cliVersionLine(), - provider: cfg.provider, - model: cfg.model, - workspaceRoot: options.workspaceRoot, - sessionPath, - readOnly, - streaming: useStream, - plain, - gitBranch, - chatTheme: cfg.chatTheme, - }), - ); - - const persist = async () => { - if (sessionPath) await saveSessionFile(sessionPath, history); - }; - - const persistEmpty = async () => { - if (sessionPath) await saveSessionFile(sessionPath, []); - }; - - try { - while (true) { - const line = await rl.question(replPrompt(plain, cfg.chatTheme)); - const trimmed = line.trim(); - if (isExitCommand(trimmed, loc)) break; - if (!trimmed) continue; - - const slash = await handleChatSlashInput({ - locale: loc, - cwd: options.cwd, - workspaceRoot: options.workspaceRoot, - trimmed, - getHistory: () => history, - setHistory: (h) => { - history = h; - }, - persistHistory: async (h) => { - if (sessionPath) await saveSessionFile(sessionPath, h); - }, - write: (s) => { - output.write(s); - }, - persistEmpty, - slashHelpExtra, - provider, - summarization: cfg.summarization, - }); - if (slash.kind === "exit") break; - if (slash.kind === "again") continue; - - const userText = slash.kind === "send" ? slash.text : trimmed; - - output.write(replUserBlock(loc, userText, plain, cfg.chatTheme)); - - if (useStream) { - output.write(replAssistantLead(plain, cfg.chatTheme)); - } - - const { assistantText, history: next } = await runAgentTurn({ - workspaceRoot: options.workspaceRoot, - provider, - tools, - systemPrompt: system, - userText, - history, - maxSteps: cfg.maxAgentSteps, - maxHistoryItems: cfg.maxHistoryItems, - confirm, - skipConfirm: options.skipConfirm, - readOnly, - shellPolicy: cfg.shellPolicy, - sidecarArgv, - embeddingClient: cfg.embeddings.client, - streaming: useStream, - onTextDelta: useStream - ? (chunk: string) => { - output.write(chunk); - } - : undefined, - signal: options.signal, - summarization: cfg.summarization, - }); - history = next; - if (useStream) { - output.write("\n"); - } else { - output.write(`${replAssistantLead(plain, cfg.chatTheme)}${assistantText}\n`); - } - output.write(replTurnSeparator(plain, cfg.chatTheme)); - await persist(); - } - } finally { - await persist(); - rl.close(); - } -} diff --git a/packages/cli/src/ui/chat-slash.ts b/packages/cli/src/ui/chat-slash.ts deleted file mode 100644 index 5ac3c58..0000000 --- a/packages/cli/src/ui/chat-slash.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { AgentHistoryItem, LLMProvider } from "../llm/types.js"; -import type { Locale } from "../i18n/locale.js"; -import { compactHistoryByUserTurns } from "../session/compact-history.js"; -import { summarizeHistory } from "../session/summarize-history.js"; -import { tr } from "../i18n/strings.js"; - -export type SlashLoopAction = - | { kind: "proceed" } - | { kind: "again" } - | { kind: "exit" } - | { kind: "send"; text: string }; - -type SlashCtx = { - locale: Locale; - cwd: string; - workspaceRoot: string; - trimmed: string; - getHistory: () => AgentHistoryItem[]; - setHistory: (h: AgentHistoryItem[]) => void; - persistHistory: (h: AgentHistoryItem[]) => Promise; - write: (s: string) => void | Promise; - persistEmpty: () => Promise; - /** Appended after built-in /help (e.g. skill pack slash_hints). */ - slashHelpExtra?: string; - /** Required for /compact summary. */ - provider: LLMProvider; - summarization: { enabled: boolean; threshold: number; keepRecentTurns: number }; -}; - -/** - * Handle `/…` input. Returns whether the main loop should send text to the model. - */ -export async function handleChatSlashInput(ctx: SlashCtx): Promise { - if (!ctx.trimmed.startsWith("/")) return { kind: "proceed" }; - - const raw = ctx.trimmed.slice(1).trim(); - const cmd = raw.split(/\s+/)[0]?.toLowerCase() ?? ""; - - if (cmd === "exit" || cmd === "quit") { - return { kind: "exit" }; - } - - if (cmd === "help" || cmd === "h" || cmd === "?") { - let msg = `${tr(ctx.locale, "slashHelp")}\n`; - if (ctx.slashHelpExtra) msg += ctx.slashHelpExtra; - await ctx.write(msg); - return { kind: "again" }; - } - - if (cmd === "clear" || cmd === "cls") { - ctx.setHistory([]); - await ctx.persistEmpty(); - await ctx.write(`${tr(ctx.locale, "slashCleared")}\n`); - return { kind: "again" }; - } - - if (cmd === "pick") { - const { listPickCandidatePaths, pickOneRelativePath } = await import("../pick/run-pick.js"); - const paths = await listPickCandidatePaths({ - cwd: ctx.cwd, - workspaceRoot: ctx.workspaceRoot, - }); - if (paths.length === 0) { - await ctx.write(`${tr(ctx.locale, "pickNoMatches")}\n`); - return { kind: "again" }; - } - const rel = await pickOneRelativePath({ cwd: ctx.cwd, workspaceRoot: ctx.workspaceRoot }); - if (!rel) { - await ctx.write(`${tr(ctx.locale, "slashPickCancelled")}\n`); - return { kind: "again" }; - } - await ctx.write(`${tr(ctx.locale, "slashPickSelected", { path: rel })}\n`); - return { kind: "send", text: rel }; - } - - if (cmd === "compact" || cmd === "trim") { - const rest = raw.slice(cmd.length).trim(); - - if (rest === "summary") { - const history = ctx.getHistory(); - const summarized = await summarizeHistory(history, { - provider: ctx.provider, - keepRecentTurns: ctx.summarization.keepRecentTurns, - }); - ctx.setHistory(summarized); - await ctx.persistHistory(summarized); - await ctx.write(`${tr(ctx.locale, "slashCompactSummarized")}\n`); - return { kind: "again" }; - } - - let keep = 8; - if (rest.length > 0) { - const n = parseInt(rest, 10); - if (!Number.isFinite(n) || n < 1 || n > 64) { - await ctx.write(`${tr(ctx.locale, "slashCompactUsage")}\n`); - return { kind: "again" }; - } - keep = n; - } - const before = ctx.getHistory(); - const after = compactHistoryByUserTurns(before, keep); - if (after.length === before.length) { - await ctx.write(`${tr(ctx.locale, "slashCompactNoop", { keep: String(keep) })}\n`); - return { kind: "again" }; - } - ctx.setHistory(after); - await ctx.persistHistory(after); - await ctx.write( - `${tr(ctx.locale, "slashCompactDone", { - before: String(before.length), - after: String(after.length), - keep: String(keep), - })}\n`, - ); - return { kind: "again" }; - } - - await ctx.write(`${tr(ctx.locale, "slashUnknown", { cmd: ctx.trimmed })}\n`); - return { kind: "again" }; -} diff --git a/packages/cli/src/ui/chat-tui.tsx b/packages/cli/src/ui/chat-tui.tsx deleted file mode 100644 index 30e409a..0000000 --- a/packages/cli/src/ui/chat-tui.tsx +++ /dev/null @@ -1,384 +0,0 @@ -import { useCallback, useMemo, useState } from "react"; -import { Box, Text, useApp, useInput } from "ink"; -import TextInput from "ink-text-input"; -import { resolve } from "node:path"; -import type { InariConfig } from "../config.js"; -import { loadConfig } from "../config.js"; -import { createLlmProvider } from "../llm/create-provider.js"; -import { applySkillToolAllowlist, chatToolDefinitions, knownChatToolNames } from "../llm/inari-tools.js"; -import { resolveSkillsContext } from "../skills/resolve-context.js"; -import type { AgentHistoryItem, InariToolDefinition, LLMProvider } from "../llm/types.js"; -import { createChatSystemPrompt, runAgentTurn } from "../agent/loop.js"; -import type { ConfirmFn } from "../tools/engine-run.js"; -import type { EmbeddingClient } from "../tools/embeddings-api.js"; -import { loadSessionFile, saveSessionFile } from "../session/file-session.js"; -import { cliVersionLine } from "../pkg-meta.js"; -import { tr } from "../i18n/strings.js"; -import { isExitCommand, isAffirmativeKey, isNegativeKey } from "../i18n/prompts.js"; -import { buildTuiChromeLines, tuiAccentColor } from "./chat-chrome.js"; -import { handleChatSlashInput } from "./chat-slash.js"; -import { getWorkspaceGitBranch } from "./git-branch.js"; - -type ConfirmState = { - title: string; - body: string; - resolve: (ok: boolean) => void; -}; - -export type RunChatTuiOptions = { - cwd: string; - workspaceRoot: string; - skipConfirm: boolean; - sessionFile?: string; - noStream: boolean; - readOnlyCli: boolean; - plainCli: boolean; - signal?: AbortSignal; - providerCli?: string; - modelCli?: string; -}; - -type Bootstrapped = { - cfg: InariConfig; - provider: LLMProvider; - system: string; - readOnly: boolean; - useStream: boolean; - tools: InariToolDefinition[]; - sidecarArgv: string[] | null; - embeddingClient: EmbeddingClient | null; - sessionPath: string | null; - gitBranch: string | null; - plain: boolean; - slashHelpExtra: string | undefined; -}; - -function ChatTuiInner( - props: RunChatTuiOptions & Bootstrapped & { initialHistory: AgentHistoryItem[] }, -) { - const { exit } = useApp(); - const loc = props.cfg.locale; - const plain = props.plain; - const accent = tuiAccentColor(props.cfg.chatTheme, plain); - - const chrome = useMemo( - () => - buildTuiChromeLines({ - locale: loc, - version: cliVersionLine(), - provider: props.cfg.provider, - model: props.cfg.model, - workspaceRoot: props.workspaceRoot, - sessionPath: props.sessionPath, - readOnly: props.readOnly, - streaming: props.useStream, - plain, - gitBranch: props.gitBranch, - chatTheme: props.cfg.chatTheme, - }), - [ - loc, - props.cfg.provider, - props.cfg.model, - props.workspaceRoot, - props.sessionPath, - props.readOnly, - props.useStream, - plain, - props.gitBranch, - props.cfg.chatTheme, - ], - ); - - const [transcript, setTranscript] = useState(""); - const [streaming, setStreaming] = useState(""); - const [input, setInput] = useState(""); - const [busy, setBusy] = useState(false); - const [confirmState, setConfirmState] = useState(null); - const [history, setHistory] = useState(props.initialHistory); - - const persist = useCallback( - async (next: AgentHistoryItem[]) => { - if (props.sessionPath) await saveSessionFile(props.sessionPath, next); - }, - [props.sessionPath], - ); - - const persistEmpty = useCallback(async () => { - if (props.sessionPath) await saveSessionFile(props.sessionPath, []); - }, [props.sessionPath]); - - const makeConfirm = useCallback((): ConfirmFn => { - return ({ title, body }) => - new Promise((resolve) => { - setConfirmState({ title, body, resolve }); - }); - }, []); - - useInput( - (ch, key) => { - if (!confirmState) return; - if (isAffirmativeKey(ch, loc)) { - confirmState.resolve(true); - setConfirmState(null); - } else if (isNegativeKey(ch, loc) || key.escape) { - confirmState.resolve(false); - setConfirmState(null); - } - }, - { isActive: Boolean(confirmState) }, - ); - - const onSubmit = useCallback( - async (value: string) => { - const trimmed = value.trim(); - if (!trimmed || busy || confirmState) return; - - if (isExitCommand(trimmed, loc)) { - await persist(history); - exit(); - return; - } - - const slash = await handleChatSlashInput({ - locale: loc, - cwd: props.cwd, - workspaceRoot: props.workspaceRoot, - trimmed, - getHistory: () => history, - setHistory, - persistHistory: persist, - write: (s) => setTranscript((t) => t + s), - persistEmpty, - slashHelpExtra: props.slashHelpExtra, - provider: props.provider, - summarization: props.cfg.summarization, - }); - if (slash.kind === "exit") { - await persist(history); - exit(); - return; - } - if (slash.kind === "again") { - return; - } - - const userText = slash.kind === "send" ? slash.text : trimmed; - - setInput(""); - setBusy(true); - setStreaming(""); - const you = tr(loc, "chatReplYou"); - setTranscript((t) => `${t}\n${you} › ${userText}\n`); - - const confirmFn = props.skipConfirm ? async () => true : makeConfirm(); - - try { - const { assistantText, history: next } = await runAgentTurn({ - workspaceRoot: props.workspaceRoot, - provider: props.provider, - tools: props.tools, - systemPrompt: props.system, - userText, - history, - maxSteps: props.cfg.maxAgentSteps, - maxHistoryItems: props.cfg.maxHistoryItems, - confirm: confirmFn, - skipConfirm: props.skipConfirm, - readOnly: props.readOnly, - shellPolicy: props.cfg.shellPolicy, - sidecarArgv: props.sidecarArgv, - embeddingClient: props.embeddingClient, - streaming: props.useStream, - onTextDelta: props.useStream ? (chunk: string) => setStreaming((s) => s + chunk) : undefined, - signal: props.signal, - summarization: props.cfg.summarization, - }); - setHistory(next); - await persist(next); - setTranscript((t) => `${t}assistant\n${assistantText}\n`); - setStreaming(""); - } catch (e) { - setTranscript((t) => `${t}\n[error] ${String(e)}\n`); - setStreaming(""); - } finally { - setBusy(false); - } - }, - [ - busy, - confirmState, - exit, - history, - makeConfirm, - persist, - persistEmpty, - loc, - props.cfg.maxAgentSteps, - props.cfg.maxHistoryItems, - props.cfg.shellPolicy, - props.sidecarArgv, - props.embeddingClient, - props.provider, - props.readOnly, - props.signal, - props.skipConfirm, - props.system, - props.tools, - props.useStream, - props.workspaceRoot, - props.cwd, - props.slashHelpExtra, - ], - ); - - const chromePanel = plain ? ( - - - {chrome.title} — {chrome.subtitle} - - {chrome.modelLine} - {chrome.workspaceLine} - {chrome.branchLine ? {chrome.branchLine} : null} - {chrome.sessionLine ? {chrome.sessionLine} : null} - {chrome.badges} - {chrome.hint} - - ) : ( - - - - {chrome.title} - - - - {chrome.subtitle} - - - {chrome.modelLine} - {chrome.workspaceLine} - {chrome.branchLine ? {chrome.branchLine} : null} - {chrome.sessionLine ? {chrome.sessionLine} : null} - {chrome.badges} - {chrome.hint} - - ); - - if (confirmState) { - const c = confirmState; - const confirmBox = plain ? ( - - - {tr(loc, "confirmTitle")} {c.title} - - {c.body} - - ) : ( - - - {tr(loc, "confirmTitle")} {c.title} - - {c.body} - - ); - return ( - - {chromePanel} - - - {transcript} - {streaming} - - - {confirmBox} - {tr(loc, "tuiConfirmYes")} - - ); - } - - return ( - - {chromePanel} - - {transcript} - {streaming ? ( - - assistant - {streaming} - - ) : null} - - {busy ? ( - plain ? ( - {tr(loc, "tuiBusy")} - ) : ( - {tr(loc, "tuiBusy")} - ) - ) : ( - - {plain ? "> " : "› "} - { - void onSubmit(value); - }} - /> - - )} - - ); -} - -export async function runChatTui(options: RunChatTuiOptions): Promise { - const plain = options.plainCli || process.env.INARI_PLAIN === "1"; - const cfg = await loadConfig(options.cwd, { - provider: options.providerCli, - model: options.modelCli, - }); - const provider = createLlmProvider(cfg); - const readOnly = cfg.readOnly || options.readOnlyCli; - const useStream = cfg.streaming && !options.noStream; - const sidecarArgv = cfg.sidecar.argv; - const includeCodebaseSearch = sidecarArgv !== null; - const includeSemanticSearch = cfg.embeddings.client !== null; - const known = knownChatToolNames({ - readOnly, - includeCodebaseSearch, - includeSemanticSearch, - }); - const skillsCtx = await resolveSkillsContext(options.cwd, cfg.skillPackPaths, known); - let tools = chatToolDefinitions(readOnly, includeCodebaseSearch, includeSemanticSearch); - tools = applySkillToolAllowlist(tools, skillsCtx.toolAllowlist); - const system = createChatSystemPrompt(options.workspaceRoot, skillsCtx.systemPromptAppendix); - const slashHelpExtra = - skillsCtx.slashHints.length > 0 - ? `\nSkill hints:\n${skillsCtx.slashHints.map((l) => ` · ${l}`).join("\n")}\n` - : undefined; - const sessionPath = options.sessionFile ? resolve(options.cwd, options.sessionFile) : null; - const initialHistory: AgentHistoryItem[] = sessionPath - ? await loadSessionFile(sessionPath) - : []; - const gitBranch = await getWorkspaceGitBranch(options.workspaceRoot); - - const { render } = await import("ink"); - const { waitUntilExit } = render( - , - ); - await waitUntilExit(); -} diff --git a/packages/cli/src/ui/git-branch.ts b/packages/cli/src/ui/git-branch.ts deleted file mode 100644 index 167dfae..0000000 --- a/packages/cli/src/ui/git-branch.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -/** Current branch name for `workspaceRoot`, or null if not a git repo / detached / error. */ -export async function getWorkspaceGitBranch(workspaceRoot: string): Promise { - try { - const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { - cwd: workspaceRoot, - maxBuffer: 256, - }); - const b = stdout.toString().trim(); - if (!b || b === "HEAD") return null; - return b; - } catch { - return null; - } -} diff --git a/packages/cli/src/ui/logo.ts b/packages/cli/src/ui/logo.ts deleted file mode 100644 index 24b5931..0000000 --- a/packages/cli/src/ui/logo.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import type { Locale } from "../i18n/locale.js"; -import { tr } from "../i18n/strings.js"; - -/** 256-color ANSI: orange hoodie, brown outline, cream accent (kitsune mascot). */ -const A = { - o: "\x1b[38;5;208m", - O: "\x1b[38;5;214m", - b: "\x1b[38;5;94m", - w: "\x1b[38;5;230m", - d: "\x1b[38;5;240m", - x: "\x1b[0m", - bold: "\x1b[1m", - dim: "\x1b[2m", -}; - -export function ansiLogoEnabled(): boolean { - if (process.env.NO_COLOR != null && process.env.NO_COLOR !== "") return false; - if (process.env.FORCE_COLOR === "0") return false; - return process.stdout.isTTY === true; -} - -function z(use: boolean): typeof A { - if (!use) { - return { o: "", O: "", b: "", w: "", d: "", x: "", bold: "", dim: "" }; - } - return A; -} - -/** - * Absolute path to bundled mascot PNG (pixel fox · Inari). - * From `dist/ui/logo.js` → `packages/cli/assets/logo.png`. - */ -export function resolveBundledLogoPath(): string | null { - const here = dirname(fileURLToPath(import.meta.url)); - const p = join(here, "..", "..", "assets", "logo.png"); - return existsSync(p) ? p : null; -} - -/** Full banner: `inari doctor`, `inari logo`. Pass `cliVersionLine()` from `pkg-meta`. */ -export function inariLogoBannerFull(versionLine: string, locale: Locale): string { - const c = z(ansiLogoEnabled()); - const title = `${c.bold}${c.w}InariCode${c.x} ${c.dim}${versionLine}${c.x}`; - const sub = `${c.dim}${tr(locale, "logoSub")}${c.x}`; - const kitsune = `${c.b}${tr(locale, "logoKitsune")}${c.x}`; - const usb = `${c.d}[⌂]${c.x} ${tr(locale, "logoUsb")}`; - const cmds = `${c.dim}${tr(locale, "logoCommands")}${c.x}`; - const png = resolveBundledLogoPath(); - const mascotLine = png - ? tr(locale, "logoMascot", { path: png }) - : tr(locale, "logoMascotMissing"); - return [ - "", - ` ${c.o}▄▄▄▄▄▄▄▄${c.x} ${title}`, - ` ${c.o}██${c.b}▀${c.o}▀${c.b}▀${c.o}▀${c.b}▀${c.o}██${c.x} ${c.O}__/\\__${c.x} ${kitsune}`, - ` ${c.o}█${c.w}o${c.b}.${c.b}.${c.w}o${c.o}█${c.x} ${c.O}/ ${c.w}w${c.O} \\${c.x} ${sub}`, - ` ${c.o}█${c.b}█${c.o}██${c.b}█${c.o}█${c.b}█${c.o}█${c.x} ${usb} ${cmds}`, - ` ${c.o}▀${c.b}██████${c.o}▀${c.x}`, - ` ${c.O}‾${c.b}~~${c.w}██${c.b}~~${c.O}‾${c.x} ${c.dim}${mascotLine}${c.x}`, - "", - ].join("\n"); -} - -/** One-line header for `inari chat` / TUI. */ -export function inariLogoBannerCompact(versionLine: string, locale: Locale): string { - const c = z(ansiLogoEnabled()); - return ( - `${c.o}▄▄▄${c.x} ${c.bold}${c.w}InariCode${c.x} ${c.dim}${versionLine}${c.x} ` + - `${c.O}__/\\__${c.x} ${c.b}·${c.x} ${c.dim}${tr(locale, "logoCompactHints")}${c.x}\n` - ); -} - -/** Prepended to `inari --help`. */ -export function inariHelpPreamble(versionLine: string, locale: Locale): string { - return `${inariLogoBannerCompact(versionLine, locale)}\n`; -} diff --git a/packages/cli/src/utils/concurrency-pool.ts b/packages/cli/src/utils/concurrency-pool.ts deleted file mode 100644 index 6d7f656..0000000 --- a/packages/cli/src/utils/concurrency-pool.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** Concurrency-limited async executor for tool calls to prevent resource exhaustion. */ - -type PendingTask = { - fn: () => Promise; - resolve: (value: T) => void; - reject: (error: unknown) => void; -}; - -export class ConcurrencyPool { - private concurrency: number; - private running = 0; - private queue: PendingTask[] = []; - - constructor(concurrency: number) { - this.concurrency = Math.max(1, concurrency); - } - - /** Execute a task, respecting concurrency limits. */ - async run(fn: () => Promise): Promise { - if (this.running < this.concurrency) { - this.running++; - try { - return await fn(); - } finally { - this.running--; - this.processQueue(); - } - } - - // Queue the task - return new Promise((resolve, reject) => { - this.queue.push({ fn, resolve, reject } as PendingTask); - }); - } - - private processQueue(): void { - if (this.queue.length === 0 || this.running >= this.concurrency) return; - - const task = this.queue.shift()!; - this.running++; - task - .fn() - .then(task.resolve) - .catch(task.reject) - .finally(() => { - this.running--; - this.processQueue(); - }); - } - - /** Get current stats for observability */ - stats(): { running: number; queued: number; concurrency: number } { - return { running: this.running, queued: this.queue.length, concurrency: this.concurrency }; - } -} - -/** Default global tool executor with concurrency limit of 3 */ -const defaultPool = new ConcurrencyPool(3); - -/** - * Execute a tool through the concurrency-limited pool. - * Use for I/O-heavy operations like codebase_search or file reads. - */ -export async function executeTool(fn: () => Promise): Promise { - return defaultPool.run(fn); -} - -/** Create a custom pool with different concurrency limits */ -export function createConcurrencyPool(concurrency: number): ConcurrencyPool { - return new ConcurrencyPool(concurrency); -} diff --git a/packages/cli/src/utils/config-cache.ts b/packages/cli/src/utils/config-cache.ts deleted file mode 100644 index 4e0db7c..0000000 --- a/packages/cli/src/utils/config-cache.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** Config cache layer: memoizes validated config with file-mtime invalidation. */ - -import { stat } from "node:fs/promises"; -import { resolve } from "node:path"; -import type { InariConfig, RawInariConfig } from "../config.js"; -import { loadRawInariConfig, resolveConfigFromRaw, applyInariEnvOverrides } from "../config.js"; -import { inaricodeConfigSearchPlaces } from "../config-paths.js"; - -type CacheEntry = { - config: InariConfig; - raw: RawInariConfig; - /** Epoch ms of the newest config file stat */ - mtimeMs: number; - /** Config file path that was cached */ - configPath: string | null; -}; - -let cache: CacheEntry | null = null; - -async function getNewestConfigFileMtime(searchFrom: string): Promise<{ mtimeMs: number; path: string | null }> { - const places = inaricodeConfigSearchPlaces(); - let newest = 0; - let foundPath: string | null = null; - for (const place of places) { - try { - const fullPath = resolve(searchFrom, place); - const s = await stat(fullPath); - if (s.mtimeMs > newest) { - newest = s.mtimeMs; - foundPath = fullPath; - } - } catch { - // File doesn't exist, skip - } - } - return { mtimeMs: newest, path: foundPath }; -} - -/** - * Load config with disk-cache invalidation. Re-parses only when a config file changes. - * Env overrides (INARI_PROVIDER, etc.) are still applied on every call. - */ -export async function loadCachedConfig(searchFrom: string): Promise { - const { mtimeMs, path } = await getNewestConfigFileMtime(searchFrom); - - // Cache hit: return if files haven't changed - if (cache && cache.configPath === path && cache.mtimeMs === mtimeMs) { - // Env overrides still apply (user might have changed env between calls) - const rawWithEnv = applyInariEnvOverrides(cache.raw); - return resolveConfigFromRaw(rawWithEnv); - } - - // Cache miss or stale: reload from disk - const raw = await loadRawInariConfig(searchFrom); - const config = resolveConfigFromRaw(applyInariEnvOverrides(raw)); - - cache = { config, raw, mtimeMs, configPath: path }; - return config; -} - -/** Force cache invalidation (e.g., after `inari init` writes new config). */ -export function invalidateConfigCache(): void { - cache = null; -} diff --git a/packages/cli/src/utils/env-validator.ts b/packages/cli/src/utils/env-validator.ts deleted file mode 100644 index e4c1ecf..0000000 --- a/packages/cli/src/utils/env-validator.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** Production environment validation: ensures required tools and env vars are available. */ - -import { existsSync } from "node:fs"; -import { cpus } from "node:os"; - -export type EnvValidationResult = { - ok: boolean; - warnings: string[]; - errors: string[]; - /** Environment summary for telemetry */ - summary: Record; -}; - -/** - * Validate production environment. - * Returns warnings (non-blocking) and errors (blocking). - */ -export function validateProductionEnv(): EnvValidationResult { - const warnings: string[] = []; - const errors: string[] = []; - const summary: Record = {}; - - // Node.js version check - const nodeVersion = process.version; - const nodeMajor = parseInt(nodeVersion.slice(1).split(".")[0], 10); - summary.nodeVersion = nodeVersion; - if (nodeMajor < 20) { - errors.push(`Node.js >= 20 required (found ${nodeVersion})`); - } - - // System info - summary.cpus = cpus().length; - summary.platform = process.platform; - summary.arch = process.arch; - - // Check for API keys (warn if missing, not error — user might configure later) - const apiKeys = [ - { name: "ANTHROPIC_API_KEY", provider: "Anthropic" }, - { name: "OPENAI_API_KEY", provider: "OpenAI" }, - { name: "HF_TOKEN", provider: "Hugging Face" }, - { name: "GOOGLE_API_KEY", provider: "Google Gemini" }, - ]; - - const availableKeys = apiKeys.filter((k) => process.env[k.name]); - summary.apiKeysConfigured = availableKeys.length; - if (availableKeys.length === 0) { - warnings.push( - "No API keys configured in environment. Users must set at least one provider key.", - ); - } - - // Engine binary check (for subprocess IPC mode) - const enginePath = process.env.INARI_ENGINE_PATH; - if (enginePath) { - summary.enginePath = enginePath; - if (!existsSync(enginePath)) { - errors.push(`INARI_ENGINE_PATH points to non-existent file: ${enginePath}`); - } else { - summary.engineExists = true; - } - } - - // IPC mode validation - const ipcMode = process.env.INARI_ENGINE_IPC?.trim().toLowerCase(); - summary.ipcMode = ipcMode || "auto"; - if (ipcMode === "native") { - try { - require.resolve("@inaricode/engine-native"); - summary.nativeAddonLoaded = true; - } catch { - errors.push("INARI_ENGINE_IPC=native but @inaricode/engine-native not found"); - } - } - - // Production logging validation - const logLevel = process.env.INARI_LOG; - if (logLevel && !["json", "debug", "info", "error"].includes(logLevel)) { - warnings.push(`Unknown INARI_LOG value: ${logLevel} (expected: json, debug, info, error)`); - } - - // Language validation - const lang = process.env.INARI_LANG; - if (lang && !["en", "mn"].includes(lang)) { - warnings.push(`Unknown INARI_LANG value: ${lang} (expected: en, mn)`); - } - - // Memory check - const heapLimit = process.env.NODE_OPTIONS?.includes("--max-old-space-size"); - if (heapLimit) { - summary.maxOldSpaceSizeSet = true; - } - - // Sidecar check - const sidecarEnabled = process.env.INARI_SIDECAR_ENABLED === "true"; - if (sidecarEnabled) { - summary.sidecarEnabled = true; - } - - return { - ok: errors.length === 0, - warnings, - errors, - summary, - }; -} - -/** Print validation results to console (used by `inari doctor` and startup). */ -export function printValidationResult(result: EnvValidationResult): void { - if (result.errors.length > 0) { - console.error("\n❌ Environment validation failed:"); - for (const err of result.errors) { - console.error(` • ${err}`); - } - } - - if (result.warnings.length > 0) { - console.warn("\n⚠️ Warnings:"); - for (const warn of result.warnings) { - console.warn(` • ${warn}`); - } - } - - if (result.ok && result.warnings.length === 0) { - console.log("\n✅ Environment validation passed"); - } - - console.log("\nSummary:"); - console.log(` Node.js: ${result.summary.nodeVersion}`); - console.log(` Platform: ${result.summary.platform} (${result.summary.arch})`); - console.log(` CPUs: ${result.summary.cpus}`); - console.log(` API keys configured: ${result.summary.apiKeysConfigured}`); - if (result.summary.enginePath) { - console.log(` Engine path: ${result.summary.enginePath}`); - } - console.log(` IPC mode: ${result.summary.ipcMode}`); -} diff --git a/packages/cli/src/utils/retry-executor.ts b/packages/cli/src/utils/retry-executor.ts deleted file mode 100644 index bb4fe86..0000000 --- a/packages/cli/src/utils/retry-executor.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** Retry executor with exponential backoff and jitter for resilient LLM/sidecar calls. */ - -export type RetryOptions = { - /** Maximum retry attempts (default: 3) */ - maxRetries?: number; - /** Initial delay in ms (default: 1000) */ - initialDelayMs?: number; - /** Maximum delay in ms (default: 30_000) */ - maxDelayMs?: number; - /** Exponential multiplier (default: 2) */ - backoffMultiplier?: number; - /** Add jitter to avoid thundering herd (default: true) */ - jitter?: boolean; - /** Retry on these error codes (default: 429, 500, 502, 503, 504) */ - retryableStatuses?: number[]; -}; - -const DEFAULT_RETRYABLE_STATUSES = [429, 500, 502, 503, 504]; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function calculateDelay(attempt: number, opts: Required): number { - const exponential = opts.initialDelayMs * opts.backoffMultiplier ** attempt; - const capped = Math.min(exponential, opts.maxDelayMs); - if (!opts.jitter) return capped; - // Add ±25% jitter - const jitterRange = capped * 0.25; - return capped + (Math.random() - 0.5) * 2 * jitterRange; -} - -function isRetryableError(error: unknown, retryableStatuses: number[]): boolean { - if (error instanceof Error) { - // Check HTTP status in error message or properties - const statusMatch = (error as unknown as Record).status as number | undefined; - if (statusMatch && retryableStatuses.includes(statusMatch)) return true; - - const message = error.message.toLowerCase(); - if (message.includes("rate limit") || message.includes("429")) return true; - if (message.includes("too many requests")) return true; - if (message.includes("service unavailable")) return true; - if (message.includes("gateway timeout")) return true; - - // Network errors worth retrying - if (message.includes("econnreset") || message.includes("econnrefused")) return true; - if (message.includes("etimedout") || message.includes("socket hang up")) return true; - } - return false; -} - -/** - * Execute an async operation with exponential backoff retry. - * Preserves error.cause chain per InariCode standards. - */ -export async function withRetry(fn: () => Promise, opts: RetryOptions = {}): Promise { - const options: Required = { - maxRetries: opts.maxRetries ?? 3, - initialDelayMs: opts.initialDelayMs ?? 1000, - maxDelayMs: opts.maxDelayMs ?? 30_000, - backoffMultiplier: opts.backoffMultiplier ?? 2, - jitter: opts.jitter ?? true, - retryableStatuses: opts.retryableStatuses ?? DEFAULT_RETRYABLE_STATUSES, - }; - - let lastError: unknown; - for (let attempt = 0; attempt <= options.maxRetries; attempt++) { - try { - return await fn(); - } catch (error) { - lastError = error; - if (attempt === options.maxRetries) break; - - if (!isRetryableError(error, options.retryableStatuses)) { - throw error; // Non-retryable, fail immediately - } - - const delay = calculateDelay(attempt, options); - // Log retry attempt if JSON logging is enabled - if (process.env.INARI_LOG === "json") { - const logLine = JSON.stringify({ - event: "retry_attempt", - attempt, - maxRetries: options.maxRetries, - delayMs: Math.round(delay), - error: error instanceof Error ? error.message : String(error), - }); - process.stderr.write(`${logLine}\n`); - } - - await sleep(delay); - } - } - - // Exhausted retries — rethrow with cause chain - const err = lastError instanceof Error ? lastError : new Error(String(lastError)); - if (lastError instanceof Error && err !== lastError) { - err.cause = lastError; - } - throw err; -} diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 0000000..e3cae3f --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,16 @@ +export const VERSION = "0.2.0"; + +export const FLOWERS = [ + "Sakura", "Rose", "Tulip", "Lotus", "Sunflower", + "Daisy", "Jasmine", "Lavender", "Orchid", "Cherry" +] as const; + +export function getCodename(): string { + const parts = VERSION.split("."); + const patch = parseInt(parts[2] ?? "0", 10); + return FLOWERS[patch % FLOWERS.length]; +} + +export function versionLine(): string { + return `v${VERSION} · ${getCodename()}`; +} \ No newline at end of file diff --git a/packages/cli/src/workspace-root.ts b/packages/cli/src/workspace-root.ts deleted file mode 100644 index 24aff83..0000000 --- a/packages/cli/src/workspace-root.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { resolve } from "node:path"; - -export function resolveWorkspaceRoot(flag: string | undefined, cwd: string): string { - return resolve(cwd, flag ?? "."); -} diff --git a/packages/cli/test/token-estimate.test.ts b/packages/cli/test/token-estimate.test.ts new file mode 100644 index 0000000..cdb92c2 --- /dev/null +++ b/packages/cli/test/token-estimate.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + estimateTokens, + estimateHistoryTokens, + formatTokenCount, +} from "../src/utils/token-estimate.js"; +import type { AgentHistoryItem } from "../src/llm/types.js"; + +function u(t: string): AgentHistoryItem { + return { kind: "user_text", text: t }; +} +function a(text: string): AgentHistoryItem { + return { kind: "assistant", blocks: [{ type: "text", text }] }; +} +function tool(id: string, content: string): AgentHistoryItem { + return { kind: "tool_outputs", outputs: [{ id, content }] }; +} + +describe("estimateTokens", () => { + it("returns 0 for empty string", () => { + expect(estimateTokens("")).toBe(0); + }); + + it("rounds up chars/4", () => { + expect(estimateTokens("abcd")).toBe(1); // 4/4 = 1 + expect(estimateTokens("abcde")).toBe(2); // 5/4 = 1.25 → ceil = 2 + expect(estimateTokens("a".repeat(100))).toBe(25); + }); +}); + +describe("formatTokenCount", () => { + it("returns plain number below 1000", () => { + expect(formatTokenCount(0)).toBe("0"); + expect(formatTokenCount(320)).toBe("320"); + expect(formatTokenCount(999)).toBe("999"); + }); + + it("formats thousands with one decimal", () => { + expect(formatTokenCount(1000)).toBe("1k"); + expect(formatTokenCount(1200)).toBe("1.2k"); + expect(formatTokenCount(1250)).toBe("1.3k"); // rounds + expect(formatTokenCount(42000)).toBe("42k"); + }); + + it("formats millions", () => { + expect(formatTokenCount(1_000_000)).toBe("1M"); + expect(formatTokenCount(1_500_000)).toBe("1.5M"); + }); +}); + +describe("estimateHistoryTokens", () => { + it("returns zeros for empty history", () => { + const result = estimateHistoryTokens([]); + expect(result).toEqual({ input: 0, output: 0, total: 0, byTurn: [] }); + }); + + it("counts user_text as input, assistant as output", () => { + // "aaaa" = 4 chars = 1 token; "bbbbbbbb" = 8 chars = 2 tokens + const h: AgentHistoryItem[] = [u("aaaa"), a("bbbbbbbb")]; + const result = estimateHistoryTokens(h); + expect(result.input).toBe(1); + expect(result.output).toBe(2); + expect(result.total).toBe(3); + }); + + it("counts tool_outputs as input", () => { + const h: AgentHistoryItem[] = [ + u("aaaa"), + { kind: "tool_outputs", outputs: [{ id: "t1", content: "aaaa" }] }, + a("bbbb"), + ]; + const result = estimateHistoryTokens(h); + expect(result.input).toBe(2); // user + tool + expect(result.output).toBe(1); + }); + + it("segments into turns correctly", () => { + const h: AgentHistoryItem[] = [u("aaaa"), a("bbbb"), u("cccc"), a("dddd")]; + const result = estimateHistoryTokens(h); + expect(result.byTurn).toHaveLength(2); + expect(result.byTurn[0]).toEqual({ turn: 1, input: 1, output: 1 }); + expect(result.byTurn[1]).toEqual({ turn: 2, input: 1, output: 1 }); + }); + + it("counts tool_use blocks in assistant as output", () => { + const h: AgentHistoryItem[] = [ + { + kind: "assistant", + blocks: [ + { type: "text", text: "aaaa" }, + { + type: "tool_use", + id: "t1", + name: "read_file", + input: { path: "x" }, + }, + ], + }, + ]; + const result = estimateHistoryTokens(h); + expect(result.output).toBeGreaterThan(0); + }); +});