diff --git a/README.ko.md b/README.ko.md index 6f29ac8..2e653fa 100644 --- a/README.ko.md +++ b/README.ko.md @@ -312,7 +312,7 @@ args = ["@pleaseai/csp", "mcp"] ## 서브 에이전트 설정 -Claude Code, Gemini CLI, Cursor, OpenCode, GitHub Copilot CLI, Kiro는 모두 전용 `csp` 검색 서브 에이전트를 지원합니다. 프로젝트 루트에서 `csp init`을 한 번 실행하세요. +Claude Code, Gemini CLI, Cursor, OpenCode, GitHub Copilot CLI, Kiro, Antigravity, Command Code, Pi, Reasonix는 모두 전용 `csp` 검색 서브 에이전트를 지원합니다. 프로젝트 루트에서 `csp init`을 한 번 실행하세요. ```bash csp init # Claude Code → .claude/agents/csp-search.md @@ -321,6 +321,10 @@ csp init --agent cursor # Cursor → .cursor/agents/csp-search.md csp init --agent opencode # OpenCode → .opencode/agents/csp-search.md csp init --agent copilot # Copilot CLI → .github/agents/csp-search.md csp init --agent kiro # Kiro → .kiro/agents/csp-search.md +csp init --agent antigravity # Antigravity → .antigravity/agents/csp-search.md +csp init --agent commandcode # Command Code → .commandcode/agents/csp-search.md +csp init --agent pi # Pi → .pi/agents/csp-search.md +csp init --agent reasonix # Reasonix → .reasonix/agents/csp-search.md ``` `csp`가 `$PATH`에 없다면 명령 앞에 `bunx @pleaseai/csp`를 붙이세요. @@ -363,21 +367,45 @@ csp savings --verbose # 호출 유형별 분해 포함 ``` ``` - csp Token Savings - ════════════════════════════════════════════════════════════════ - Period Calls Savings - ──────────────────────────────────────────────────────────────── - Today 42 [███████████████░] ~58.4k tokens (95%) - Last 7 days 287 [██████████████░░] ~312.4k tokens (90%) - All time 1.4k [██████████████░░] ~1.2M tokens (89%) + Csp Token Savings + ════════════════════════════════════════════════════════════════════════ + + Total saved: ~1.2M tokens (89%) + Total calls: 1.4k + Efficiency: █████████████████████░░░ 89% + + By Period + ──────────────────────────────────────────────────────────────────────── + Period Calls Saved Ratio + ──────────────────────────────────────────────────────────────────────── + Today 42 ~58.4k tokens ███████████████████████░ 95% + Last 7 days 287 ~312.4k tokens █████████████████████░░░ 90% + All time 1.4k ~1.2M tokens █████████████████████░░░ 89% ``` 절약량 계산: 각 호출마다 반환된 청크가 속한 파일들의 총 문자 수와 반환된 스니펫의 문자 수를 기록합니다. 절약된 토큰 추정치는 `(파일 문자 수 − 스니펫 문자 수) / 4` (1토큰 ≈ 4문자). 이는 보수적인 추정으로, 기준선은 "에이전트가 매칭된 파일을 통째로 읽는다"는 일반적인 코딩 에이전트 동작입니다. +stdout이 컬러를 지원하는 TTY일 때 출력에 색이 입혀집니다(`NO_COLOR`, `dumb` 터미널, 파이프 연결 시에는 비활성화). `--verbose`를 주면 "By Call Type" 분해가 추가됩니다. + 통계는 `~/.csp/savings.jsonl`에 저장됩니다. +
+캐시 비우기 + +`csp clear`는 캐시된 데이터를 삭제합니다. + +```bash +csp clear savings # ~/.csp/savings.jsonl 삭제 +csp clear all # 현재는 savings와 동일 +csp clear index # 안내만 출력 (아래 참고) +``` + +인덱스 영속화는 아직 연결되지 않았고, 저장 모델(repo-local `.csp/` vs 글로벌 캐시)도 미결이라 현재 `clear index`는 비울 대상이 없습니다. `csp index -o <경로>`는 지정한 경로에만 기록하므로 해당 디렉토리는 직접 삭제하세요. + +
+
라이브러리 사용 diff --git a/README.md b/README.md index ccd99ec..1e4b97e 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ By default the MCP server indexes only code files. To also index documentation, ## Sub-agent setup -Claude Code, Gemini CLI, Cursor, OpenCode, GitHub Copilot CLI, and Kiro all support a dedicated `csp` search sub-agent. Run `csp init` once in your project root: +Claude Code, Gemini CLI, Cursor, OpenCode, GitHub Copilot CLI, Kiro, Antigravity, Command Code, Pi, and Reasonix all support a dedicated `csp` search sub-agent. Run `csp init` once in your project root: ```bash csp init # Claude Code → .claude/agents/csp-search.md @@ -321,6 +321,10 @@ csp init --agent cursor # Cursor → .cursor/agents/csp-search.md csp init --agent opencode # OpenCode → .opencode/agents/csp-search.md csp init --agent copilot # Copilot CLI → .github/agents/csp-search.md csp init --agent kiro # Kiro → .kiro/agents/csp-search.md +csp init --agent antigravity # Antigravity → .antigravity/agents/csp-search.md +csp init --agent commandcode # Command Code → .commandcode/agents/csp-search.md +csp init --agent pi # Pi → .pi/agents/csp-search.md +csp init --agent reasonix # Reasonix → .reasonix/agents/csp-search.md ``` If `csp` is not on `$PATH`, prefix the command with `bunx @pleaseai/csp`. @@ -363,21 +367,45 @@ csp savings --verbose # also show breakdown by call type ``` ``` - csp Token Savings - ════════════════════════════════════════════════════════════════ - Period Calls Savings - ──────────────────────────────────────────────────────────────── - Today 42 [███████████████░] ~58.4k tokens (95%) - Last 7 days 287 [██████████████░░] ~312.4k tokens (90%) - All time 1.4k [██████████████░░] ~1.2M tokens (89%) + Csp Token Savings + ════════════════════════════════════════════════════════════════════════ + + Total saved: ~1.2M tokens (89%) + Total calls: 1.4k + Efficiency: █████████████████████░░░ 89% + + By Period + ──────────────────────────────────────────────────────────────────────── + Period Calls Saved Ratio + ──────────────────────────────────────────────────────────────────────── + Today 42 ~58.4k tokens ███████████████████████░ 95% + Last 7 days 287 ~312.4k tokens █████████████████████░░░ 90% + All time 1.4k ~1.2M tokens █████████████████████░░░ 89% ``` Savings are calculated as follows: for each call, `csp` records the total character count of the unique files containing returned chunks and the character count of the snippets returned. Estimated tokens saved is `(file chars − snippet chars) / 4` (4 chars per token). This is a conservative estimate: the baseline is reading matched files in full, which is how coding agents often explore unfamiliar code. +Output is colorized when stdout is a color-capable TTY (suppressed under `NO_COLOR`, a `dumb` terminal, or when piped). `--verbose` adds a "By Call Type" breakdown. + Stats are stored in `~/.csp/savings.jsonl`.
+
+Clear + +`csp clear` removes cached data: + +```bash +csp clear savings # delete ~/.csp/savings.jsonl +csp clear all # currently the same as `savings` +csp clear index # prints a note (see below) +``` + +Index persistence is not wired up yet, and the storage model — repo-local `.csp/` vs a global cache — is still undecided, so `clear index` has nothing to remove for now. `csp index -o ` writes only to the path you pass; delete those directories yourself. + +
+
Library usage diff --git a/src/agents/antigravity.md b/src/agents/antigravity.md new file mode 100644 index 0000000..adaaeec --- /dev/null +++ b/src/agents/antigravity.md @@ -0,0 +1,58 @@ +--- +name: csp-search +description: Code search agent for exploring any codebase. Use for finding code by intent, locating implementations, understanding how something works, or discovering related code. Prefer over run_shell_command/read_file for any semantic or exploratory question. +tools: + - run_shell_command + - read_file +--- + +Use `csp search` to find code by describing what it does or naming a symbol/identifier, instead of grep: + +```bash +csp search "authentication flow" ./my-project +csp search "save_pretrained" ./my-project +csp search "save model to disk" ./my-project --top-k 10 +``` + +If you anticipate doing more than one search, use `csp index` to create an index. + +```bash +csp index ./my-project -o my_index +``` + +You can then reuse this index later on: + +```bash +csp search "save_pretrained" --index my_index +``` + +An index is not automatically updated, so if the code changes significantly, reindex. If you notice stale results while resolving searches to files, reindex. + +Use `--content docs` to search documentation and prose, `--content config` for config files (yaml, toml, etc.), or `--content all` to search code, docs, and config: + +```bash +csp search "deployment guide" ./my-project --content docs +csp search "database host port" ./my-project --content config +csp search "authentication" ./my-project --content all +``` + +Use `csp find-related` to discover code similar to a known location (pass `filePath` and `line` from a prior search result): + +```bash +csp find-related src/auth.ts 42 ./my-project +``` + +Like search, `find-related` also accepts an `--index` argument. + +`path` defaults to the current directory when omitted; git URLs are accepted. + +If `csp` is not on `$PATH`, use `bunx @pleaseai/csp` in its place. + +### Workflow + +1. Index the repo using `csp index -o cached_index`. +2. Start with `csp search` to find relevant chunks. Pass the index to achieve results faster. +3. Use `--content docs` for documentation, `--content config` for config files, or `--content all` for everything. +4. Inspect full files only when the returned chunk does not give enough context. +5. Optionally use `csp find-related` with a promising result's `filePath` and `line` to discover related implementations. +6. Use grep only when you need exhaustive literal matches or quick confirmation of an exact string. \ No newline at end of file diff --git a/src/agents/commandcode.md b/src/agents/commandcode.md new file mode 100644 index 0000000..aa008b7 --- /dev/null +++ b/src/agents/commandcode.md @@ -0,0 +1,56 @@ +--- +name: csp-search +description: Code search agent for exploring any codebase. Use for finding code by intent, locating implementations, understanding how something works, or discovering related code. Prefer over bash/read_file for any semantic or exploratory question. +tools: bash, read_file +--- + +Use `csp search` to find code by describing what it does or naming a symbol/identifier, instead of grep: + +```bash +csp search "authentication flow" ./my-project +csp search "save_pretrained" ./my-project +csp search "save model to disk" ./my-project --top-k 10 +``` + +If you anticipate doing more than one search, use `csp index` to create an index. + +```bash +csp index ./my-project -o my_index +``` + +You can then reuse this index later on: + +```bash +csp search "save_pretrained" --index my_index +``` + +An index is not automatically updated, so if the code changes significantly, reindex. If you notice stale results while resolving searches to files, reindex. + +Use `--content docs` to search documentation and prose, `--content config` for config files (yaml, toml, etc.), or `--content all` to search code, docs, and config: + +```bash +csp search "deployment guide" ./my-project --content docs +csp search "database host port" ./my-project --content config +csp search "authentication" ./my-project --content all +``` + +Use `csp find-related` to discover code similar to a known location (pass `filePath` and `line` from a prior search result): + +```bash +csp find-related src/auth.ts 42 ./my-project +``` + +Like search, `find-related` also accepts an `--index` argument. + +`path` defaults to the current directory when omitted; git URLs are accepted. + +If `csp` is not on `$PATH`, use `bunx @pleaseai/csp` in its place. + +### Workflow + +1. Index the repo using `csp index -o cached_index`. +2. Start with `csp search` to find relevant chunks. Pass the index to achieve results faster. +3. Use `--content docs` for documentation, `--content config` for config files, or `--content all` for everything. +4. Inspect full files only when the returned chunk does not give enough context. +5. Optionally use `csp find-related` with a promising result's `filePath` and `line` to discover related implementations. +6. Use grep only when you need exhaustive literal matches or quick confirmation of an exact string. \ No newline at end of file diff --git a/src/agents/pi.md b/src/agents/pi.md new file mode 100644 index 0000000..374f998 --- /dev/null +++ b/src/agents/pi.md @@ -0,0 +1,55 @@ +--- +name: csp-search +description: Code search agent for exploring any codebase. Use for finding code by intent, locating implementations, understanding how something works, or discovering related code. Prefer over Bash/Read for any semantic or exploratory question. +--- + +Use `csp search` to find code by describing what it does or naming a symbol/identifier, instead of grep: + +```bash +csp search "authentication flow" ./my-project +csp search "save_pretrained" ./my-project +csp search "save model to disk" ./my-project --top-k 10 +``` + +If you anticipate doing more than one search, use `csp index` to create an index. + +```bash +csp index ./my-project -o my_index +``` + +You can then reuse this index later on: + +```bash +csp search "save_pretrained" --index my_index +``` + +An index is not automatically updated, so if the code changes significantly, reindex. If you notice stale results while resolving searches to files, reindex. + +Use `--content docs` to search documentation and prose, `--content config` for config files (yaml, toml, etc.), or `--content all` to search code, docs, and config: + +```bash +csp search "deployment guide" ./my-project --content docs +csp search "database host port" ./my-project --content config +csp search "authentication" ./my-project --content all +``` + +Use `csp find-related` to discover code similar to a known location (pass `filePath` and `line` from a prior search result): + +```bash +csp find-related src/auth.ts 42 ./my-project +``` + +Like search, `find-related` also accepts an `--index` argument. + +`path` defaults to the current directory when omitted; git URLs are accepted. + +If `csp` is not on `$PATH`, use `bunx @pleaseai/csp` in its place. + +### Workflow + +1. Index the repo using `csp index -o cached_index`. +2. Start with `csp search` to find relevant chunks. Pass the index to achieve results faster. +3. Use `--content docs` for documentation, `--content config` for config files, or `--content all` for everything. +4. Inspect full files only when the returned chunk does not give enough context. +5. Optionally use `csp find-related` with a promising result's `filePath` and `line` to discover related implementations. +6. Use grep only when you need exhaustive literal matches or quick confirmation of an exact string. \ No newline at end of file diff --git a/src/agents/reasonix.md b/src/agents/reasonix.md new file mode 100644 index 0000000..9353344 --- /dev/null +++ b/src/agents/reasonix.md @@ -0,0 +1,57 @@ +--- +name: csp-search +description: Code search agent for exploring any codebase. Use for finding code by intent, locating implementations, understanding how something works, or discovering related code. Prefer over bash/read_file for any semantic or exploratory question. +runAs: subagent +allowed-tools: bash, read_file +--- + +Use `csp search` to find code by describing what it does or naming a symbol/identifier, instead of grep: + +```bash +csp search "authentication flow" ./my-project +csp search "save_pretrained" ./my-project +csp search "save model to disk" ./my-project --top-k 10 +``` + +If you anticipate doing more than one search, use `csp index` to create an index. + +```bash +csp index ./my-project -o my_index +``` + +You can then reuse this index later on: + +```bash +csp search "save_pretrained" --index my_index +``` + +An index is not automatically updated, so if the code changes significantly, reindex. If you notice stale results while resolving searches to files, reindex. + +Use `--content docs` to search documentation and prose, `--content config` for config files (yaml, toml, etc.), or `--content all` to search code, docs, and config: + +```bash +csp search "deployment guide" ./my-project --content docs +csp search "database host port" ./my-project --content config +csp search "authentication" ./my-project --content all +``` + +Use `csp find-related` to discover code similar to a known location (pass `filePath` and `line` from a prior search result): + +```bash +csp find-related src/auth.ts 42 ./my-project +``` + +Like search, `find-related` also accepts an `--index` argument. + +`path` defaults to the current directory when omitted; git URLs are accepted. + +If `csp` is not on `$PATH`, use `bunx @pleaseai/csp` in its place. + +### Workflow + +1. Index the repo using `csp index -o cached_index`. +2. Start with `csp search` to find relevant chunks. Pass the index to achieve results faster. +3. Use `--content docs` for documentation, `--content config` for config files, or `--content all` for everything. +4. Inspect full files only when the returned chunk does not give enough context. +5. Optionally use `csp find-related` with a promising result's `filePath` and `line` to discover related implementations. +6. Use grep only when you need exhaustive literal matches or quick confirmation of an exact string. \ No newline at end of file diff --git a/src/cli.test.ts b/src/cli.test.ts index 3ae60dc..5ff6c56 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -12,12 +12,16 @@ import { ContentType, type SearchResult } from './types.ts' describe('Agent enum', () => { test('enum values', () => { + expect(String(Agent.Antigravity)).toBe('antigravity') expect(String(Agent.Claude)).toBe('claude') + expect(String(Agent.Commandcode)).toBe('commandcode') expect(String(Agent.Copilot)).toBe('copilot') expect(String(Agent.Cursor)).toBe('cursor') expect(String(Agent.Gemini)).toBe('gemini') expect(String(Agent.Kiro)).toBe('kiro') expect(String(Agent.Opencode)).toBe('opencode') + expect(String(Agent.Pi)).toBe('pi') + expect(String(Agent.Reasonix)).toBe('reasonix') }) }) @@ -34,6 +38,12 @@ describe('_agentPath', () => { test('opencode → .opencode/agents/csp-search.md', () => { expect(_agentPath(Agent.Opencode)).toBe('.opencode/agents/csp-search.md') }) + test('antigravity → .antigravity/agents/csp-search.md', () => { + expect(_agentPath(Agent.Antigravity)).toBe('.antigravity/agents/csp-search.md') + }) + test('reasonix → .reasonix/agents/csp-search.md', () => { + expect(_agentPath(Agent.Reasonix)).toBe('.reasonix/agents/csp-search.md') + }) }) describe('parseArgs', () => { @@ -281,6 +291,91 @@ describe('csp savings', () => { }) }) +describe('csp clear', () => { + function captureStdout(): { writes: string[], restore: () => void } { + const writes: string[] = [] + const origWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write + return { writes, restore: () => { process.stdout.write = origWrite } } + } + + test('clear savings deletes the file and reports the path', async () => { + const { writes, restore } = captureStdout() + let called = 0 + try { + const code = await runCli(['clear', 'savings'], { + clearSavings: () => { called++; return { path: '/tmp/x/savings.jsonl', cleared: true } }, + }) + expect(code).toBe(0) + } + finally { + restore() + } + expect(called).toBe(1) + expect(writes.join('')).toContain('Cleared savings at `/tmp/x/savings.jsonl`') + }) + + test('clear savings reports when no file exists', async () => { + const { writes, restore } = captureStdout() + try { + await runCli(['clear', 'savings'], { + clearSavings: () => ({ path: '/tmp/x/savings.jsonl', cleared: false }), + }) + } + finally { + restore() + } + expect(writes.join('')).toContain('No savings file found at `/tmp/x/savings.jsonl`') + }) + + test('clear index notes there is no managed index cache', async () => { + const { writes, restore } = captureStdout() + let called = 0 + try { + await runCli(['clear', 'index'], { + clearSavings: () => { called++; return { path: '/tmp/x/savings.jsonl', cleared: true } }, + }) + } + finally { + restore() + } + expect(called).toBe(0) // index-only must not touch savings + expect(writes.join('')).toContain('No index cache to clear') + }) + + test('clear all clears savings and notes the index', async () => { + const { writes, restore } = captureStdout() + try { + await runCli(['clear', 'all'], { + clearSavings: () => ({ path: '/tmp/x/savings.jsonl', cleared: true }), + }) + } + finally { + restore() + } + const out = writes.join('') + expect(out).toContain('No index cache to clear') + expect(out).toContain('Cleared savings at') + }) + + test('clear with an invalid type exits 1', async () => { + const code = await runCli(['clear', 'bogus'], { + clearSavings: () => ({ path: '/tmp/x/savings.jsonl', cleared: true }), + }) + expect(code).toBe(1) + }) + + test('clear with no type exits 1', async () => { + const code = await runCli(['clear'], { + clearSavings: () => ({ path: '/tmp/x/savings.jsonl', cleared: true }), + }) + expect(code).toBe(1) + }) +}) + describe('csp mcp', () => { test('dispatches to serve with path and content', async () => { let captured: { path?: string | undefined, ref?: string | undefined, content?: ContentType[] } = {} @@ -334,6 +429,13 @@ describe('_readAgentFile', () => { expect(text.length).toBeGreaterThan(0) expect(text).toContain('csp') }) + test('a bundled template exists for every agent', async () => { + for (const agent of Object.values(Agent)) { + const text = await _readAgentFile(agent) + expect(text).toContain('name: csp-search') + expect(text).toContain('csp search') + } + }) }) describe('runCli error handling', () => { diff --git a/src/cli.ts b/src/cli.ts index 5f45522..81ddca5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,17 +8,21 @@ import { fileURLToPath } from 'node:url' // TODO(integration): replace stub when sibling modules land import { CspIndex } from './indexing/index.ts' import { serve } from './mcp/server.ts' -import { formatSavingsReport } from './stats.ts' +import { clearSavings, formatSavingsReport } from './stats.ts' import { ContentType } from './types.ts' import { formatResults, isGitUrl, resolveChunk } from './utils.ts' export enum Agent { + Antigravity = 'antigravity', Claude = 'claude', + Commandcode = 'commandcode', Copilot = 'copilot', Cursor = 'cursor', Gemini = 'gemini', Kiro = 'kiro', Opencode = 'opencode', + Pi = 'pi', + Reasonix = 'reasonix', } const DEFAULT_AGENT = Agent.Claude @@ -27,12 +31,15 @@ const CLI_DISPATCH_ARGS = new Set([ 'find-related', 'init', 'savings', + 'clear', 'index', 'mcp', '-h', '--help', ]) +const CLEAR_CHOICES = ['all', 'index', 'savings'] as const + const CONTENT_CHOICES = ['code', 'docs', 'config', 'all'] as const export function _agentPath(agent: Agent): string { @@ -189,13 +196,14 @@ Commands: find-related [path] Find code similar to a specific location. init Write a csp sub-agent file for your coding agent. savings Show token savings and usage stats. + clear Clear cached data (savings telemetry). mcp [path] Start the MCP server (optionally pre-index path). Common options: --top-k , -k Number of results (default: 5). --content Content types: code, docs, config, all (default: code). --index Path to a pre-built index. - --agent , -a One of: claude, copilot, cursor, gemini, kiro, opencode. + --agent , -a One of: antigravity, claude, commandcode, copilot, cursor, gemini, kiro, opencode, pi, reasonix. --force Overwrite if file already exists (init). -o, --out Write the pre-built index to this path (index). --ref Branch or tag for git URLs (mcp). @@ -221,6 +229,7 @@ interface RunOptions { writeFileImpl?: (path: string, content: string) => Promise readAgentFile?: (agent: Agent) => Promise formatSavings?: (opts: { verbose: boolean }) => string + clearSavings?: () => { path: string, cleared: boolean } cwd?: () => string } @@ -279,6 +288,44 @@ async function _runIndex(opts: { await index.save(out) } +/** + * Run the `clear` subcommand. + * + * `clear savings` (and `all`) deletes the `~/.csp/savings.jsonl` telemetry + * file. `clear index` is currently a no-op note: index persistence is not + * wired up yet (the `CspIndex` orchestrator is a stub), and the storage model + * — repo-local `.csp/` vs a global cache — is still undecided. For now + * `csp index -o ` writes only to the path you pass, so delete those + * directories yourself. + */ +export function _runClear( + type: string, + clearSavingsImpl: () => { path: string, cleared: boolean } = clearSavings, +): number { + if (!(CLEAR_CHOICES as readonly string[]).includes(type)) { + process.stderr.write(`Invalid clear type: ${type}. Choices: ${CLEAR_CHOICES.join(', ')}\n`) + return 1 + } + + if (type === 'index' || type === 'all') { + process.stdout.write( + 'No index cache to clear — index persistence is not wired up yet; ' + + '`csp index -o ` writes only to the path you choose.\n', + ) + } + + if (type === 'savings' || type === 'all') { + const { path: statsPath, cleared } = clearSavingsImpl() + process.stdout.write( + cleared + ? `Cleared savings at \`${statsPath}\`\n` + : `No savings file found at \`${statsPath}\`\n`, + ) + } + + return 0 +} + export async function runCli(argv: string[], options: RunOptions = {}): Promise { // Bare invocation prints help and exits 0; unknown subcommands are handled // below (after parsing) so they exit 1. @@ -340,6 +387,17 @@ export async function runCli(argv: string[], options: RunOptions = {}): Promise< return 0 } + if (command === 'clear') { + const type = positional[0] + if (type === undefined) { + process.stderr.write(`clear requires a type. Choices: ${CLEAR_CHOICES.join(', ')}\n`) + return 1 + } + return options.clearSavings + ? _runClear(type, options.clearSavings) + : _runClear(type) + } + if (command === 'mcp') { const path = positional[0] const ref = _getStringFlag(flags, 'ref') @@ -421,7 +479,18 @@ export async function runCli(argv: string[], options: RunOptions = {}): Promise< } function _coerceAgent(raw: string): Agent { - const candidates: Agent[] = [Agent.Claude, Agent.Copilot, Agent.Cursor, Agent.Gemini, Agent.Kiro, Agent.Opencode] + const candidates: Agent[] = [ + Agent.Antigravity, + Agent.Claude, + Agent.Commandcode, + Agent.Copilot, + Agent.Cursor, + Agent.Gemini, + Agent.Kiro, + Agent.Opencode, + Agent.Pi, + Agent.Reasonix, + ] for (const a of candidates) { if (a === raw) return a } diff --git a/src/stats.test.ts b/src/stats.test.ts index cb08457..886a5f7 100644 --- a/src/stats.test.ts +++ b/src/stats.test.ts @@ -6,6 +6,7 @@ import path from 'node:path' import { BucketStats, buildSavingsSummary, + clearSavings, formatSavingsReport, resetStatsFile, saveSearchStats, @@ -209,6 +210,21 @@ describe('buildSavingsSummary', () => { }) }) +describe('clearSavings', () => { + test('deletes an existing stats file and reports cleared=true', () => { + appendFileSync(statsFile, `${JSON.stringify({ ts: 1, call: 'search', results: 1, snippet_chars: 1, file_chars: 2 })}\n`) + expect(existsSync(statsFile)).toBe(true) + const result = clearSavings() + expect(result).toEqual({ path: statsFile, cleared: true }) + expect(existsSync(statsFile)).toBe(false) + }) + + test('reports cleared=false when no stats file exists', () => { + expect(existsSync(statsFile)).toBe(false) + expect(clearSavings()).toEqual({ path: statsFile, cleared: false }) + }) +}) + describe('formatSavingsReport', () => { test('shows "Csp Token Savings" header and bucket labels', () => { const now = Date.now() / 1000 @@ -242,8 +258,13 @@ describe('formatSavingsReport', () => { expect(report).toContain('~1.5k') }) - test('verbose appends Usage Breakdown section with sorted call types', () => { + test('verbose appends By Call Type section sorted by call count descending', () => { const now = Date.now() / 1000 + // search appears 2×, find_related 1× ⇒ search ranks first by count. + appendFileSync( + statsFile, + `${JSON.stringify({ ts: now, call: 'search', results: 1, snippet_chars: 100, file_chars: 400 })}\n`, + ) appendFileSync( statsFile, `${JSON.stringify({ ts: now, call: 'search', results: 1, snippet_chars: 100, file_chars: 400 })}\n`, @@ -254,26 +275,35 @@ describe('formatSavingsReport', () => { ) const report = formatSavingsReport({ verbose: true }) - expect(report).toContain('Usage Breakdown') + expect(report).toContain('By Call Type') expect(report).toContain('Call type') expect(report).toContain('search') expect(report).toContain('find_related') - // Sorted alphabetically — find_related should appear before search. - const findIdx = report.indexOf('find_related') - const searchHeadingsStripped = report.replace('Csp Token Savings', '') - const searchIdx = searchHeadingsStripped.indexOf('search') - expect(findIdx).toBeLessThan(searchIdx + 'Csp Token Savings'.length) + // Sorted by count descending — the more frequent `search` precedes `find_related`. + const searchIdx = report.indexOf('By Call Type') + const breakdown = report.slice(searchIdx) + expect(breakdown.indexOf('search')).toBeLessThan(breakdown.indexOf('find_related')) + }) + + test('By Call Type section is hidden without --verbose', () => { + const now = Date.now() / 1000 + appendFileSync( + statsFile, + `${JSON.stringify({ ts: now, call: 'search', results: 1, snippet_chars: 100, file_chars: 400 })}\n`, + ) + expect(formatSavingsReport()).not.toContain('By Call Type') }) - test('renders bar with filled blocks proportional to ratio', () => { + test('renders By Period bar with filled blocks proportional to ratio', () => { const now = Date.now() / 1000 - // ratio = 0.75 ⇒ 12 filled / 4 empty out of 16. + // ratio = 0.75 over a 24-wide bar ⇒ 18 filled / 6 empty. appendFileSync( statsFile, `${JSON.stringify({ ts: now, call: 'search', results: 1, snippet_chars: 100, file_chars: 400 })}\n`, ) const report = formatSavingsReport() - expect(report).toContain('[████████████░░░░]') + expect(report).toContain(`${'█'.repeat(18)}${'░'.repeat(6)}`) + // Overall ratio is surfaced in the headline summary as "(75%)". expect(report).toContain('(75%)') }) diff --git a/src/stats.ts b/src/stats.ts index 02ea0c9..2970732 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -1,7 +1,8 @@ // Port of src/semble/stats.py -import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import path from 'node:path' +import process from 'node:process' /** * Call type for token-savings tracking. @@ -114,6 +115,27 @@ export function saveSearchStats( } } +/** + * Delete the savings stats file if it exists. + * + * Deletion (not truncation) mirrors semble's `clear` (`path.unlink()`) and + * lets `csp savings` fall back to the "No stats yet" message — a truncated, + * still-present file would instead render an all-zero report. Best-effort: + * a permission error or broken symlink is swallowed and reported as + * `cleared: false` rather than crashing the CLI. + */ +export function clearSavings(): { path: string, cleared: boolean } { + if (!existsSync(_STATS_FILE)) + return { path: _STATS_FILE, cleared: false } + try { + rmSync(_STATS_FILE) + return { path: _STATS_FILE, cleared: true } + } + catch { + return { path: _STATS_FILE, cleared: false } + } +} + interface StatsRecord { ts: number call: string @@ -215,6 +237,33 @@ function padRight(s: string, width: number): string { return s + ' '.repeat(width - s.length) } +function padLeft(s: string, width: number): string { + if (s.length >= width) + return s + return ' '.repeat(width - s.length) + s +} + +/** + * Whether ANSI colors should be emitted. Mirrors semble's `_use_color`: + * suppressed under `NO_COLOR`, a `dumb` terminal, or a non-TTY stdout. + */ +function useColor(): boolean { + return !('NO_COLOR' in process.env) + && process.env.TERM !== 'dumb' + && Boolean(process.stdout.isTTY) +} + +/** Wrap `text` in an ANSI color `code` when `enabled`. */ +function color(code: string, text: string, enabled: boolean): string { + return enabled ? `[${code}m${text}` : text +} + +/** Color a savings percentage by value: green ≥80, yellow ≥50, red below. */ +function colorRatio(pct: number, enabled: boolean): string { + const code = pct >= 80 ? '32' : pct >= 50 ? '33' : '31' + return color(code, `${pct}%`, enabled) +} + function formatSavedTokens(savedTokens: number): string { if (savedTokens >= 1_000_000) return `~${(savedTokens / 1_000_000).toFixed(1)}M` @@ -235,8 +284,12 @@ export interface FormatSavingsReportOptions { /** * Return a formatted token-savings report. * - * Output mirrors semble's ASCII bar chart byte-for-byte, with the header - * swapped from "Semble Token Savings" → "Csp Token Savings". + * Adopts semble's redesigned layout (PR #197): a headline summary + * (Total saved / Total calls / Efficiency bar) followed by a "By Period" + * table, with ANSI color when stdout is a color-capable TTY. Two csp + * divergences are preserved: the header reads "Csp Token Savings" (not + * "Semble Token Savings"), and the "By Call Type" breakdown stays gated + * behind `--verbose` rather than always shown. */ export function formatSavingsReport(options: FormatSavingsReportOptions = {}): string { const target = options.path ?? _STATS_FILE @@ -246,44 +299,84 @@ export function formatSavingsReport(options: FormatSavingsReportOptions = {}): s return 'No stats yet. Run a search first.' const summary = buildSavingsSummary(target) - const barWidth = 16 - const heavyLine = ` ${'═'.repeat(64)}` - const lightLine = ` ${'─'.repeat(64)}` + const enabled = useColor() + const barWidth = 24 + const borderWidth = 72 + const heavyLine = ` ${color('38;5;244', '═'.repeat(borderWidth), enabled)}` + const lightLine = ` ${color('38;5;244', '─'.repeat(borderWidth), enabled)}` + + const allTime = summary.buckets['All time']! + const totalSavedTokens = Math.floor(allTime.savedChars / 4) // ~4 chars/token approximation + const overallPct = allTime.fileChars > 0 + ? Math.round((allTime.savedChars / allTime.fileChars) * 100) + : 0 + const efficiencyFilled = Math.round((overallPct / 100) * barWidth) + const efficiencyBar + = color('32', '█'.repeat(efficiencyFilled), enabled) + + color('38;5;244', '░'.repeat(barWidth - efficiencyFilled), enabled) const lines: string[] = [ '', - ' Csp Token Savings', + ` ${color('1;36', 'Csp Token Savings', enabled)}`, heavyLine, - ` ${padRight('Period', 12)} ${padRight('Calls', 6)} Savings`, + '', + ` ${color('1', 'Total saved:', enabled)} ${color('1;33', `${formatSavedTokens(totalSavedTokens)} tokens`, enabled)} (${colorRatio(overallPct, enabled)})`, + ` ${color('1', 'Total calls:', enabled)} ${color('1;33', formatCalls(allTime.calls), enabled)}`, + ` ${color('1', 'Efficiency:', enabled)} ${efficiencyBar} ${colorRatio(overallPct, enabled)}`, + '', + ` ${color('1', 'By Period', enabled)}`, + lightLine, + ` ${padRight('Period', 14)} ${padLeft('Calls', 8)} ${padLeft('Saved', 14)} Ratio`, lightLine, ] for (const [label, bucket] of Object.entries(summary.buckets)) { - const savedTokens = Math.floor(bucket.savedChars / 4) // ~4 chars/token approximation - const savedStr = formatSavedTokens(savedTokens) + const savedTokens = Math.floor(bucket.savedChars / 4) + const savedStr = `${formatSavedTokens(savedTokens)} tokens` const callsStr = formatCalls(bucket.calls) + let rowBar: string + let ratioStr: string if (bucket.fileChars > 0) { const ratio = bucket.savedChars / bucket.fileChars const filled = Math.round(ratio * barWidth) - const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled) - const pct = Math.round(ratio * 100) - lines.push(` ${padRight(label, 12)} ${padRight(callsStr, 6)} [${bar}] ${savedStr} tokens (${pct}%)`) + rowBar = color('32', '█'.repeat(filled), enabled) + color('38;5;244', '░'.repeat(barWidth - filled), enabled) + ratioStr = colorRatio(Math.round(ratio * 100), enabled) } else { - lines.push(` ${padRight(label, 12)} ${padRight(callsStr, 6)} [${'░'.repeat(barWidth)}] ${savedStr} tokens`) + rowBar = color('38;5;244', '░'.repeat(barWidth), enabled) + ratioStr = color('38;5;244', '–', enabled) } + lines.push( + ` ${color('1', padRight(label, 14), enabled)} ${color('1;33', padLeft(callsStr, 8), enabled)} ` + + `${color('1;33', padLeft(savedStr, 14), enabled)} ${rowBar} ${ratioStr}`, + ) } const callTypeEntries = Object.entries(summary.callTypeCounts) if (verbose && callTypeEntries.length > 0) { - lines.push('', ' Usage Breakdown', lightLine, ` ${padRight('Call type', 16)} Calls`) - const sorted = callTypeEntries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - for (const [callType, count] of sorted) { - const countStr = count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count) - lines.push(` ${padRight(callType, 16)} ${countStr}`) - } - lines.push(heavyLine) + lines.push( + '', + ` ${color('1', 'By Call Type', enabled)}`, + lightLine, + ` ${padRight('#', 4)} ${padRight('Call type', 16)} ${padLeft('Calls', 8)} Share`, + lightLine, + ) + const total = callTypeEntries.reduce((sum, [, count]) => sum + count, 0) + // Sort by call count descending; ties keep insertion order. + const sorted = [...callTypeEntries].sort(([, a], [, b]) => b - a) + sorted.forEach(([callType, count], i) => { + const share = total > 0 ? count / total : 0 + const filled = Math.max(1, Math.round(share * 16)) + const bar = color('32', '█'.repeat(filled), enabled) + color('38;5;244', '░'.repeat(16 - filled), enabled) + const rank = `${i + 1}.` + lines.push( + ` ${color('38;5;244', padRight(rank, 4), enabled)} ${padRight(callType, 16)} ` + + `${color('1;33', padLeft(formatCalls(count), 8), enabled)} ${bar} ` + + `${color('38;5;244', padLeft(`${Math.round(share * 100)}%`, 4), enabled)}`, + ) + }) } + lines.push(heavyLine) lines.push('') return lines.join('\n') }