diff --git a/.changeset/edit-diff-header.md b/.changeset/edit-diff-header.md new file mode 100644 index 00000000000..78504a9786d --- /dev/null +++ b/.changeset/edit-diff-header.md @@ -0,0 +1,5 @@ +--- +"echadron": patch +--- + +Stop repeating the file and change count above every Edit diff. The tool header already reads `Used Edit (…/readTool.ts) · +1 -1`, and the diff below it printed `+1 -1 /full/path/to/readTool.ts` again — two extra lines on the most common tool in a session, with the full path wrapping mid-word on a narrow terminal. diff --git a/.changeset/goal-stop-wrap.md b/.changeset/goal-stop-wrap.md new file mode 100644 index 00000000000..7e438838f52 --- /dev/null +++ b/.changeset/goal-stop-wrap.md @@ -0,0 +1,5 @@ +--- +"echadron": patch +--- + +Wrap the goal panel's "no stop condition" line instead of truncating it. Every other row in that panel wraps to the panel width; this one did not, so a narrow terminal cut the sentence off mid-word. diff --git a/.changeset/read-document.md b/.changeset/read-document.md new file mode 100644 index 00000000000..5875f25eddb --- /dev/null +++ b/.changeset/read-document.md @@ -0,0 +1,5 @@ +--- +"echadron": minor +--- + +Added a `ReadDocument` tool. Reads Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV and PDF files as Markdown, so the agent can work with documents instead of getting raw bytes back from `Read`. Conversion runs locally through `@firecrawl/anydoc`; nothing is uploaded. An optional dependency, matching how the native terminal helpers already ship: platforms with no prebuilt binary install fine and report that the file cannot be read, rather than failing the install or crashing. diff --git a/.changeset/token-count-decimal.md b/.changeset/token-count-decimal.md new file mode 100644 index 00000000000..c7c426dd95e --- /dev/null +++ b/.changeset/token-count-decimal.md @@ -0,0 +1,5 @@ +--- +"echadron": patch +--- + +Report context windows at the size the provider states. Token counts were formatted in 1024-based units, so a 1M-token window displayed as "977k", 500k as "488k", and 200k as "195k" — every configured window under-reported by 2.4%. Tokens are not bytes, and context sizes are configured and advertised in decimal, so they are now formatted that way. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 49448a92e72..e3e0b51e8d3 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,115 +1,116 @@ { - "name": "echadron", - "version": "0.31.1", - "description": "Echadron multi-provider agent harness for the terminal", - "license": "MIT", - "author": "YaseenHQ", - "homepage": "https://github.com/YaseenHQ/echadron/tree/main/apps/kimi-code#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/YaseenHQ/echadron.git", - "directory": "apps/kimi-code" - }, - "bugs": { - "url": "https://github.com/YaseenHQ/echadron/issues" - }, - "keywords": [ - "echadron", - "cli", - "agent", - "coding-agent", - "ai", - "tui" - ], - "bin": { - "echadron": "dist/main.mjs", - "chad": "dist/main.mjs", - "maker": "dist/main.mjs" - }, - "files": [ - "dist", - "dist-web", - "native", - "scripts/postinstall.mjs", - "README.md" - ], - "type": "module", - "imports": { - "#/tui/theme": "./src/tui/theme/index.ts", - "#/tui/commands": "./src/tui/commands/index.ts", - "#/cli/sub/web": "./src/cli/sub/web/index.ts", - "#/cli/sub/web/*": "./src/cli/sub/web/*.ts", - "#/generated/vis-web-asset": [ - "./src/generated/vis-web-asset.ts", - "./src/generated/vis-web-asset.d.ts" - ], - "#/*": "./src/*.ts" - }, - "publishConfig": { - "access": "public", - "provenance": true - }, - "scripts": { - "build": "pnpm -C ../echadron-web run build && tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs", - "prebuild": "node scripts/build-vis-asset.mjs", - "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json", - "smoke": "node scripts/smoke.mjs", - "build:native:js": "node scripts/native/01-bundle.mjs", - "build:native:sea": "node scripts/native/build.mjs --profile=local", - "build:native:release": "node scripts/native/build.mjs --profile=release", - "package:native": "node scripts/native/package.mjs", - "produce:native:manifest": "node scripts/native/produce-manifest.mjs", - "release:native:resolve": "node scripts/native/resolve-release.mjs", - "test:native:smoke": "node scripts/native/smoke.mjs", - "dev": "node scripts/dev.mjs", - "dev:cli-only": "node scripts/dev-entry.mjs", - "dev:server": "node scripts/dev-entry.mjs web --no-open --debug-endpoints", - "dev:kap-server": "node scripts/dev-entry.mjs web --no-open --debug-endpoints", - "dev:kap-server:multi": "node scripts/dev-entry.mjs web --no-open --debug-endpoints", - "dev:server:restart": "node scripts/dev-server-restart.mjs", - "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", - "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", - "dev:prod": "node dist/main.mjs", - "clean": "rm -rf dist", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "pnpm -w run build:packages && vitest run", - "e2e": "pnpm -w run build:packages && KIMI_E2E=1 vitest run test/e2e", - "e2e:real": "pnpm -w run build:packages && KIMI_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts", - "postinstall": "node scripts/postinstall.mjs" - }, - "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.9", - "node-pty": "^1.1.0" - }, - "devDependencies": { - "@moonshot-ai/acp-adapter": "workspace:^", - "@moonshot-ai/acp-server": "workspace:^", - "@moonshot-ai/agent-core-v2": "workspace:^", - "@moonshot-ai/kap-server": "workspace:^", - "@moonshot-ai/kimi-code-oauth": "workspace:^", - "@moonshot-ai/kimi-code-sdk": "workspace:^", - "@moonshot-ai/kimi-telemetry": "workspace:^", - "@moonshot-ai/migration-legacy": "workspace:^", - "@moonshot-ai/pi-tui": "workspace:^", - "@moonshot-ai/vis-server": "workspace:^", - "@moonshot-ai/vis-web": "workspace:*", - "@types/semver": "^7.7.0", - "@types/yazl": "^2.4.6", - "@yaseenhq/echadron-web": "workspace:^", - "chalk": "^5.4.1", - "cli-highlight": "^2.1.11", - "commander": "^13.1.0", - "jimp": "^1.6.1", - "pathe": "^2.0.3", - "postject": "1.0.0-alpha.6", - "semver": "^7.7.4", - "smol-toml": "^1.6.1", - "tsx": "^4.21.0", - "unicode-animations": "^1.0.3", - "yazl": "^3.3.1", - "zod": "^4.3.6" - }, - "engines": { - "node": ">=22.19.0" - } + "name": "echadron", + "version": "0.31.1", + "description": "Echadron multi-provider agent harness for the terminal", + "license": "MIT", + "author": "YaseenHQ", + "homepage": "https://github.com/YaseenHQ/echadron/tree/main/apps/kimi-code#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/YaseenHQ/echadron.git", + "directory": "apps/kimi-code" + }, + "bugs": { + "url": "https://github.com/YaseenHQ/echadron/issues" + }, + "keywords": [ + "echadron", + "cli", + "agent", + "coding-agent", + "ai", + "tui" + ], + "bin": { + "echadron": "dist/main.mjs", + "chad": "dist/main.mjs", + "maker": "dist/main.mjs" + }, + "files": [ + "dist", + "dist-web", + "native", + "scripts/postinstall.mjs", + "README.md" + ], + "type": "module", + "imports": { + "#/tui/theme": "./src/tui/theme/index.ts", + "#/tui/commands": "./src/tui/commands/index.ts", + "#/cli/sub/web": "./src/cli/sub/web/index.ts", + "#/cli/sub/web/*": "./src/cli/sub/web/*.ts", + "#/generated/vis-web-asset": [ + "./src/generated/vis-web-asset.ts", + "./src/generated/vis-web-asset.d.ts" + ], + "#/*": "./src/*.ts" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "scripts": { + "build": "pnpm -C ../echadron-web run build && tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs", + "prebuild": "node scripts/build-vis-asset.mjs", + "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json", + "smoke": "node scripts/smoke.mjs", + "build:native:js": "node scripts/native/01-bundle.mjs", + "build:native:sea": "node scripts/native/build.mjs --profile=local", + "build:native:release": "node scripts/native/build.mjs --profile=release", + "package:native": "node scripts/native/package.mjs", + "produce:native:manifest": "node scripts/native/produce-manifest.mjs", + "release:native:resolve": "node scripts/native/resolve-release.mjs", + "test:native:smoke": "node scripts/native/smoke.mjs", + "dev": "node scripts/dev.mjs", + "dev:cli-only": "node scripts/dev-entry.mjs", + "dev:server": "node scripts/dev-entry.mjs web --no-open --debug-endpoints", + "dev:kap-server": "node scripts/dev-entry.mjs web --no-open --debug-endpoints", + "dev:kap-server:multi": "node scripts/dev-entry.mjs web --no-open --debug-endpoints", + "dev:server:restart": "node scripts/dev-server-restart.mjs", + "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", + "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", + "dev:prod": "node dist/main.mjs", + "clean": "rm -rf dist", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "pnpm -w run build:packages && vitest run", + "e2e": "pnpm -w run build:packages && KIMI_E2E=1 vitest run test/e2e", + "e2e:real": "pnpm -w run build:packages && KIMI_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts", + "postinstall": "node scripts/postinstall.mjs" + }, + "optionalDependencies": { + "@firecrawl/anydoc": "^0.1.8", + "@mariozechner/clipboard": "^0.3.9", + "node-pty": "^1.1.0" + }, + "devDependencies": { + "@moonshot-ai/acp-adapter": "workspace:^", + "@moonshot-ai/acp-server": "workspace:^", + "@moonshot-ai/agent-core-v2": "workspace:^", + "@moonshot-ai/kap-server": "workspace:^", + "@moonshot-ai/kimi-code-oauth": "workspace:^", + "@moonshot-ai/kimi-code-sdk": "workspace:^", + "@moonshot-ai/kimi-telemetry": "workspace:^", + "@moonshot-ai/migration-legacy": "workspace:^", + "@moonshot-ai/pi-tui": "workspace:^", + "@moonshot-ai/vis-server": "workspace:^", + "@moonshot-ai/vis-web": "workspace:*", + "@types/semver": "^7.7.0", + "@types/yazl": "^2.4.6", + "@yaseenhq/echadron-web": "workspace:^", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "commander": "^13.1.0", + "jimp": "^1.6.1", + "pathe": "^2.0.3", + "postject": "1.0.0-alpha.6", + "semver": "^7.7.4", + "smol-toml": "^1.6.1", + "tsx": "^4.21.0", + "unicode-animations": "^1.0.3", + "yazl": "^3.3.1", + "zod": "^4.3.6" + }, + "engines": { + "node": ">=22.19.0" + } } diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index 3cd10c278d9..945d1ab6647 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -12,6 +12,7 @@ const builtins = new Set([ ]); const optionalRuntimeRequires = new Set([ + '@firecrawl/anydoc', 'ajv-formats/dist/formats', 'ajv/dist/runtime/validation_error', 'bufferutil', diff --git a/apps/kimi-code/src/tui/components/media/diff-preview.ts b/apps/kimi-code/src/tui/components/media/diff-preview.ts index 1fec48b2676..fc28fc06107 100644 --- a/apps/kimi-code/src/tui/components/media/diff-preview.ts +++ b/apps/kimi-code/src/tui/components/media/diff-preview.ts @@ -158,6 +158,12 @@ export interface ClusteredDiffOptions { readonly expandKeyHint?: string; readonly oldStart?: number; readonly newStart?: number; + /** + * Omit the `+N -M ` line. Set it when the caller already shows the + * file and the change stat in its own header, where repeating them costs + * two lines and wraps the full path mid-token on a narrow terminal. + */ + readonly omitHeader?: boolean; } interface Cluster { @@ -254,11 +260,13 @@ export function renderDiffLinesClustered( ); const output: string[] = []; - let header = ''; - if (addedCount > 0) header += s.addBold(`+${String(addedCount)} `); - if (removedCount > 0) header += s.delBold(`-${String(removedCount)} `); - header += path; - output.push(header); + if (opts.omitHeader !== true) { + let header = ''; + if (addedCount > 0) header += s.addBold(`+${String(addedCount)} `); + if (removedCount > 0) header += s.delBold(`-${String(removedCount)} `); + header += path; + output.push(header); + } if (clusters.length === 0) return output; diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts index d01f580faa2..300b393599a 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -171,11 +171,19 @@ export function buildGoalReportLines(goal: GoalSnapshot, wrapWidth: number = WRA lines.push(row('Tokens', value(formatTokenCount(goal.tokensUsed)))); if (!isComplete) { const stop = formatStopRow(goal); - lines.push( - stop !== null - ? row('Stop', value(stop)) - : muted('No stop condition — runs until evaluated complete.'), - ); + if (stop !== null) { + lines.push(row('Stop', value(stop))); + } else { + // Every other row is short enough to fit; this sentence is not, and + // pushing it unwrapped overflowed any terminal narrower than it. + for (const line of wrap( + 'No stop condition — runs until evaluated complete.', + wrapWidth, + MAX_OBJECTIVE_LINES, + )) { + lines.push(muted(line)); + } + } } return lines; } diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index d33603b3d8a..12fad2fda9c 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -2128,6 +2128,9 @@ export class ToolCallComponent extends Container { const filePath = str(this.toolCall.args['file_path'] ?? this.toolCall.args['path']); const lines = renderDiffLinesClustered(oldStr, newStr, filePath, { contextLines: 3, + // The component header already carries the elided path and the +N -M + // stat; repeating them here wrapped the full path mid-token. + omitHeader: true, ...(shouldCap ? { maxLines: COMMAND_PREVIEW_LINES } : {}), }); for (const line of lines) { diff --git a/apps/kimi-code/src/utils/usage/usage-format.ts b/apps/kimi-code/src/utils/usage/usage-format.ts index b44adb3f040..b756320253f 100644 --- a/apps/kimi-code/src/utils/usage/usage-format.ts +++ b/apps/kimi-code/src/utils/usage/usage-format.ts @@ -6,15 +6,19 @@ */ /** - * Format a token count in 1024-based units: context sizes are powers of - * two, so 262144 reads as "256k", not "262.1k". k values at or above - * 100 are rounded to whole numbers ("977k"). + * Format a token count in 1000-based units. + * + * Tokens are not bytes, and model context windows are configured and + * advertised in decimal — 200000, 500000, 1000000. Dividing by 1024 showed a + * 1M window as "977k" and a 200k window as "195k", under-reporting every + * window by 2.4% against the number the provider states. k values at or above + * 100 are rounded to whole numbers. */ export function formatTokenCount(n: number): string { if (!Number.isFinite(n) || n < 0) return '0'; - if (n >= 1024 * 1024) return `${trimDecimal(n / (1024 * 1024))}M`; - if (n >= 1024) { - const k = n / 1024; + if (n >= 1_000_000) return `${trimDecimal(n / 1_000_000)}M`; + if (n >= 1_000) { + const k = n / 1_000; return `${k >= 100 ? Math.round(k) : trimDecimal(k)}k`; } return String(n); diff --git a/apps/kimi-code/test/tui/components/dialogs/cache-hint-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/cache-hint-dialog.test.ts index 6f33c9d17dd..78f0f645d52 100644 --- a/apps/kimi-code/test/tui/components/dialogs/cache-hint-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/cache-hint-dialog.test.ts @@ -19,7 +19,7 @@ describe('CacheHintDialogComponent', () => { it('renders the title with idle duration and token count', () => { const { lines } = renderDialog(); expect( - lines.some((l) => l.includes('This session has been idle for 26d 22h and is ~286k tokens.')), + lines.some((l) => l.includes('This session has been idle for 26d 22h and is ~293k tokens.')), ).toBe(true); }); diff --git a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts index d355bb7eda5..fcccb0e81f2 100644 --- a/apps/kimi-code/test/tui/components/media/diff-preview.test.ts +++ b/apps/kimi-code/test/tui/components/media/diff-preview.test.ts @@ -65,6 +65,20 @@ describe('renderDiffLines', () => { }); describe('renderDiffLinesClustered', () => { + it('omits its own header when the caller already shows path and stat', () => { + // The tool-call header carries "Used Edit (…/foo.ts) · +1 -1", so the + // body repeating "+1 -1 /very/long/path" cost two lines and wrapped the + // full path mid-token on a narrow terminal. + const withHeader = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts'); + const without = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts', { + omitHeader: true, + }); + expect(withHeader[0]).toContain('foo.ts'); + expect(without.length).toBe(withHeader.length - 1); + expect(without.join('\n')).not.toContain('foo.ts'); + expect(without.join('\n')).toContain('X'); + }); + it('renders header with file path and counts', () => { const out = renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'foo.ts'); const text = stripAnsi(out[0]!); diff --git a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts index 43e1aa8d11b..0a67d98e1f2 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts @@ -49,13 +49,27 @@ function lines(g: GoalSnapshot): string { } describe('buildGoalReportLines', () => { + it('wraps the stop-condition line to the panel width', () => { + // Every other row honours the wrapWidth it is given; this sentence was + // pushed unwrapped, so a narrow panel truncated it instead of wrapping. + const narrow = 34; + const out = buildGoalReportLines(goal(), narrow); + const stopLines = strip(out) + .split('\n') + .filter((line) => line.includes('stop condition') || line.includes('evaluated complete')); + expect(stopLines.length).toBeGreaterThan(1); + for (const line of stopLines) { + expect(line.length, line).toBeLessThanOrEqual(narrow); + } + }); + it('renders the objective as a blockquote and key counters for an active goal', () => { const out = lines(goal()); expect(out).toContain('▌ Ship the goal status box'); expect(out).toContain('Running'); expect(out).toContain('4m 12s'); expect(out).toContain('Turns'); - expect(out).toContain('125k'); // formatTokenCount + expect(out).toContain('128k'); // formatTokenCount }); it('shows a no-stop-condition note for an unbounded active goal', () => { diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index 924b57c086c..fdb15121210 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -60,7 +60,7 @@ describe('status panel report lines', () => { expect(output).toContain('Title Implement status'); expect(output).toContain('Context window'); expect(output).toContain('25%'); - expect(output).toContain('(2.9k / 11.7k)'); + expect(output).toContain('(3k / 12k)'); expect(output).toContain('Plan usage'); expect(output).toContain('5h limit'); expect(output).toContain('8% used'); diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index acd56206a5f..e78804f5625 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -45,7 +45,7 @@ describe('UsagePanelComponent', () => { }).map(strip); expect(lines).toContain('Session usage'); - expect(lines).toContain(' kimi input 2k output 250 total 2.2k'); + expect(lines).toContain(' kimi input 2k output 250 total 2.3k'); expect(lines).toContain(' kimi cache read 500 hit 33% write 500'); expect(lines).toContain('Context window'); expect(lines.join('\n')).toContain('25%'); diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index d7fab6cd93c..2af253b531d 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -94,12 +94,12 @@ describe('FooterComponent — context NaN resilience', () => { expect(out).toMatch(/context: 1%/); }); - it('valid tokens/maxTokens → percent from tokens, counts in 1024 units', () => { + it('valid tokens/maxTokens → percent from tokens, counts in decimal units', () => { const fc = new FooterComponent( baseState({ contextUsage: 0.427, - contextTokens: 430_080, - maxContextTokens: 1_048_576, + contextTokens: 420_000, + maxContextTokens: 1_000_000, }), ); const out = strip(fc.render(200).join('')); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 8552cc69116..f25f854c07d 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -425,7 +425,7 @@ describe('KimiTUI resume message replay', () => { expect(transcript).toContain('Goal resumed'); expect(transcript).toContain('Goal blocked'); expect(transcript).toContain('Goal complete — done'); - expect(transcript).toContain('Worked 1 turn over 7m15s, using 4.2k tokens.'); + expect(transcript).toContain('Worked 1 turn over 7m15s, using 4.3k tokens.'); }); it('filters resume-normalization goal pause markers in TUI replay', async () => { @@ -471,7 +471,7 @@ describe('KimiTUI resume message replay', () => { expect(entry).toMatchObject({ kind: 'assistant', renderMode: 'markdown', - content: '✓ Goal complete.\nWorked 1 turn over 7m15s, using 4.1M tokens.', + content: '✓ Goal complete.\nWorked 1 turn over 7m15s, using 4.3M tokens.', }); }); diff --git a/apps/kimi-code/test/tui/utils/context-bar.test.ts b/apps/kimi-code/test/tui/utils/context-bar.test.ts index af09bea56fb..0fed719152d 100644 --- a/apps/kimi-code/test/tui/utils/context-bar.test.ts +++ b/apps/kimi-code/test/tui/utils/context-bar.test.ts @@ -19,7 +19,8 @@ describe('context bar', () => { }); it('keeps the context percent label and adds a bar', () => { - const line = formatContextBar(0.42, 430_080, 1_048_576).replaceAll(ANSI_SGR, ''); + // Decimal, matching how context windows are configured and advertised. + const line = formatContextBar(0.42, 420_000, 1_000_000).replaceAll(ANSI_SGR, ''); expect(line).toContain('context: 42%'); expect(line).toContain('420k/1M'); expect(line).toMatch(/[█░]/); diff --git a/apps/kimi-code/test/tui/utils/goal-completion.test.ts b/apps/kimi-code/test/tui/utils/goal-completion.test.ts index ac09feb73fa..0ef499e19a0 100644 --- a/apps/kimi-code/test/tui/utils/goal-completion.test.ts +++ b/apps/kimi-code/test/tui/utils/goal-completion.test.ts @@ -20,7 +20,7 @@ describe('buildGoalCompletionMessage', () => { const text = buildGoalCompletionMessage(snapshot()); expect(text).toContain('Goal complete — all tests pass.'); expect(text).toContain('3 turns'); - expect(text).toContain('12.2k tokens'); + expect(text).toContain('12.5k tokens'); expect(text).toContain('4m20s'); }); diff --git a/apps/kimi-code/test/utils/usage/debug-timing.test.ts b/apps/kimi-code/test/utils/usage/debug-timing.test.ts index c5981b10f61..b4ad17dd023 100644 --- a/apps/kimi-code/test/utils/usage/debug-timing.test.ts +++ b/apps/kimi-code/test/utils/usage/debug-timing.test.ts @@ -54,7 +54,7 @@ describe('formatStepDebugTiming', () => { output: 200, }, }); - expect(result).toContain('tokens in 1000'); + expect(result).toContain('tokens in 1k'); expect(result).toContain('cache read 0 (0%)'); expect(result).not.toContain('/ write 0'); }); diff --git a/apps/kimi-code/test/utils/usage/usage-format.test.ts b/apps/kimi-code/test/utils/usage/usage-format.test.ts index f2207828551..c10ab6972d4 100644 --- a/apps/kimi-code/test/utils/usage/usage-format.test.ts +++ b/apps/kimi-code/test/utils/usage/usage-format.test.ts @@ -16,27 +16,36 @@ describe('formatTokenCount', () => { expect(formatTokenCount(999)).toBe('999'); }); - it('switches to k at 1024 and trims a redundant ".0"', () => { - expect(formatTokenCount(1_000)).toBe('1000'); - expect(formatTokenCount(1_024)).toBe('1k'); - expect(formatTokenCount(1_536)).toBe('1.5k'); - expect(formatTokenCount(2_048)).toBe('2k'); + it('switches to k at 1000 and trims a redundant ".0"', () => { + expect(formatTokenCount(999)).toBe('999'); + expect(formatTokenCount(1_000)).toBe('1k'); + expect(formatTokenCount(1_500)).toBe('1.5k'); + expect(formatTokenCount(2_000)).toBe('2k'); }); it('rounds k values to 1 decimal', () => { - expect(formatTokenCount(50_552)).toBe('49.4k'); - expect(formatTokenCount(262_144)).toBe('256k'); + expect(formatTokenCount(50_552)).toBe('50.6k'); }); it('rounds k values at or above 100k to whole k', () => { - expect(formatTokenCount(102_400)).toBe('100k'); - expect(formatTokenCount(999_999)).toBe('977k'); + expect(formatTokenCount(100_000)).toBe('100k'); + expect(formatTokenCount(999_999)).toBe('1000k'); + }); + + it('reports advertised context windows at the number the provider states', () => { + // The regression this guards: 1024-based units showed a 1M window as + // "977k" and a 200k window as "195k", under-reporting every configured + // window by 2.4%. + expect(formatTokenCount(128_000)).toBe('128k'); + expect(formatTokenCount(200_000)).toBe('200k'); + expect(formatTokenCount(500_000)).toBe('500k'); + expect(formatTokenCount(1_000_000)).toBe('1M'); }); - it('switches to M at 1024*1024', () => { - expect(formatTokenCount(1_048_576)).toBe('1M'); - expect(formatTokenCount(1_572_864)).toBe('1.5M'); - expect(formatTokenCount(10_485_760)).toBe('10M'); + it('switches to M at 1000000', () => { + expect(formatTokenCount(1_000_000)).toBe('1M'); + expect(formatTokenCount(1_500_000)).toBe('1.5M'); + expect(formatTokenCount(10_000_000)).toBe('10M'); }); it('clamps negatives and NaN to 0', () => { diff --git a/apps/kimi-code/tsdown.native.config.ts b/apps/kimi-code/tsdown.native.config.ts index c1008cb6165..9cf188257fe 100644 --- a/apps/kimi-code/tsdown.native.config.ts +++ b/apps/kimi-code/tsdown.native.config.ts @@ -16,7 +16,12 @@ const builtins = new Set([ ...builtinModules, ...builtinModules.map((name) => `node:${name}`), ]); -const optionalNativeDependencies = new Set(['cpu-features']); +// Packages with napi-rs native bindings. Bundling one inlines its loader, +// which requires every platform variant it supports (`@firecrawl/anydoc-*` is +// ~20 of them) — none resolvable from a self-contained binary. Both are +// reached through guarded dynamic imports, so leaving them external degrades +// to the feature being unavailable in the SEA build rather than a crash. +const optionalNativeDependencies = new Set(['cpu-features', '@firecrawl/anydoc']); function shouldAlwaysBundle(id: string): boolean { if (builtins.has(id) || id.startsWith('node:')) return false; diff --git a/flake.nix b/flake.nix index eef3eaf25dd..333c4f616fa 100644 --- a/flake.nix +++ b/flake.nix @@ -164,7 +164,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-xY/MSfRmgHV7Nr6wsQWgWmMB6LCFSRuO4hJHOjm+wrw="; + hash = "sha256-y1YM67SckjKL0rnzbEVmfkCcH04Z+yMrayaWCwK4KCU="; }; nativeBuildInputs = [ diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json index f0c415e5e80..f01295fc4f0 100644 --- a/packages/agent-core-v2/package.json +++ b/packages/agent-core-v2/package.json @@ -1,113 +1,116 @@ { - "name": "@moonshot-ai/agent-core-v2", - "version": "0.3.0", - "private": true, - "description": "The unified agent engine for Kimi (v2 — DI Scope architecture)", - "license": "MIT", - "author": "Moonshot AI", - "homepage": "https://github.com/MoonshotAI/kimi-code/tree/main/packages/agent-core-v2#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/MoonshotAI/kimi-code.git", - "directory": "packages/agent-core-v2" - }, - "bugs": { - "url": "https://github.com/MoonshotAI/kimi-code/issues" - }, - "keywords": [ - "kimi", - "agent", - "ai", - "llm", - "session", - "tools" - ], - "files": [ - "dist" - ], - "type": "module", - "imports": { - "#/*": "./src/*.ts" - }, - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - }, - "./package.json": { - "types": "./package.json", - "default": "./package.json" - }, - "./*": { - "types": "./src/*.ts", - "default": "./src/*.ts" - } - }, - "scripts": { - "build": "tsdown", - "test": "vitest run", - "typecheck": "tsc -p tsconfig.json --noEmit", - "gen:contract-types": "node scripts/gen-contract-types.mjs", - "gen:config-manifest": "tsx --import ../../build/register-raw-text-loader.mjs scripts/gen-config-manifest.mts", - "gen:wire-manifest": "tsx --import ../../build/register-raw-text-loader.mjs scripts/gen-wire-manifest.mts", - "gen:state-manifest": "tsx scripts/gen-state-manifest.mts", - "lint:domain": "node scripts/check-domain-layers.mjs", - "clean": "rm -rf dist", - "dep-graph:analyze": "tsx scripts/dep-graph/cli.ts", - "dep-graph:dev": "vite --config scripts/dep-graph/vite.config.ts", - "dep-graph:lint": "tsx scripts/dep-graph/lint.ts" - }, - "dependencies": { - "@antfu/utils": "^9.3.0", - "@anthropic-ai/sdk": "^0.95.2", - "@google/genai": "^1.49.0", - "@jsquash/webp": "^1.5.0", - "@modelcontextprotocol/client": "2.0.0", - "@modelcontextprotocol/sdk": "^1.29.0", - "@moonshot-ai/kimi-code-oauth": "workspace:^", - "@moonshot-ai/minidb": "workspace:^", - "@moonshot-ai/protocol": "workspace:^", - "@moonshot-ai/tree-sitter-bash": "workspace:^", - "@mozilla/readability": "^0.6.0", - "ajv": "^8.18.0", - "ajv-formats": "^3.0.1", - "chokidar": "^4.0.3", - "ignore": "^5.3.2", - "jimp": "^1.6.1", - "js-yaml": "^4.1.1", - "linkedom": "^0.18.12", - "node-pty": "^1.1.0", - "openai": "^6.34.0", - "pathe": "^2.0.3", - "picomatch": "^4.0.4", - "retry": "0.13.1", - "smol-toml": "^1.6.1", - "socks": "^2.8.9", - "tar": "^7.5.13", - "ulid": "^3.0.1", - "undici": "^7.27.1", - "yauzl": "^3.3.0", - "yazl": "^3.3.1", - "zod": "^4.3.6" - }, - "devDependencies": { - "@dagrejs/dagre": "^1.1.4", - "@types/js-yaml": "^4.0.9", - "@types/picomatch": "^4.0.3", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@types/retry": "0.12.0", - "@types/sinon": "^21.0.1", - "@types/tar": "^7.0.87", - "@types/yauzl": "^2.10.3", - "@types/yazl": "^2.4.6", - "@vitejs/plugin-react": "^4.4.1", - "@xyflow/react": "^12.4.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "sinon": "^22.0.0", - "ts-morph": "^28.0.0", - "tsx": "^4.21.0", - "vite": "^6.3.3" - } + "name": "@moonshot-ai/agent-core-v2", + "version": "0.3.0", + "private": true, + "description": "The unified agent engine for Kimi (v2 \u2014 DI Scope architecture)", + "license": "MIT", + "author": "Moonshot AI", + "homepage": "https://github.com/MoonshotAI/kimi-code/tree/main/packages/agent-core-v2#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/MoonshotAI/kimi-code.git", + "directory": "packages/agent-core-v2" + }, + "bugs": { + "url": "https://github.com/MoonshotAI/kimi-code/issues" + }, + "keywords": [ + "kimi", + "agent", + "ai", + "llm", + "session", + "tools" + ], + "files": [ + "dist" + ], + "type": "module", + "imports": { + "#/*": "./src/*.ts" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./package.json": { + "types": "./package.json", + "default": "./package.json" + }, + "./*": { + "types": "./src/*.ts", + "default": "./src/*.ts" + } + }, + "scripts": { + "build": "tsdown", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit", + "gen:contract-types": "node scripts/gen-contract-types.mjs", + "gen:config-manifest": "tsx --import ../../build/register-raw-text-loader.mjs scripts/gen-config-manifest.mts", + "gen:wire-manifest": "tsx --import ../../build/register-raw-text-loader.mjs scripts/gen-wire-manifest.mts", + "gen:state-manifest": "tsx scripts/gen-state-manifest.mts", + "lint:domain": "node scripts/check-domain-layers.mjs", + "clean": "rm -rf dist", + "dep-graph:analyze": "tsx scripts/dep-graph/cli.ts", + "dep-graph:dev": "vite --config scripts/dep-graph/vite.config.ts", + "dep-graph:lint": "tsx scripts/dep-graph/lint.ts" + }, + "dependencies": { + "@antfu/utils": "^9.3.0", + "@anthropic-ai/sdk": "^0.95.2", + "@google/genai": "^1.49.0", + "@jsquash/webp": "^1.5.0", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "@moonshot-ai/kimi-code-oauth": "workspace:^", + "@moonshot-ai/minidb": "workspace:^", + "@moonshot-ai/protocol": "workspace:^", + "@moonshot-ai/tree-sitter-bash": "workspace:^", + "@mozilla/readability": "^0.6.0", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", + "chokidar": "^4.0.3", + "ignore": "^5.3.2", + "jimp": "^1.6.1", + "js-yaml": "^4.1.1", + "linkedom": "^0.18.12", + "node-pty": "^1.1.0", + "openai": "^6.34.0", + "pathe": "^2.0.3", + "picomatch": "^4.0.4", + "retry": "0.13.1", + "smol-toml": "^1.6.1", + "socks": "^2.8.9", + "tar": "^7.5.13", + "ulid": "^3.0.1", + "undici": "^7.27.1", + "yauzl": "^3.3.0", + "yazl": "^3.3.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@dagrejs/dagre": "^1.1.4", + "@types/js-yaml": "^4.0.9", + "@types/picomatch": "^4.0.3", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@types/retry": "0.12.0", + "@types/sinon": "^21.0.1", + "@types/tar": "^7.0.87", + "@types/yauzl": "^2.10.3", + "@types/yazl": "^2.4.6", + "@vitejs/plugin-react": "^4.4.1", + "@xyflow/react": "^12.4.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "sinon": "^22.0.0", + "ts-morph": "^28.0.0", + "tsx": "^4.21.0", + "vite": "^6.3.3" + }, + "optionalDependencies": { + "@firecrawl/anydoc": "^0.1.8" + } } diff --git a/packages/agent-core-v2/src/agent/tools/os/readDocument/read-document.md b/packages/agent-core-v2/src/agent/tools/os/readDocument/read-document.md new file mode 100644 index 00000000000..7b2b326b979 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/readDocument/read-document.md @@ -0,0 +1,5 @@ +Read a Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, or PDF file as Markdown. + +Use this instead of `Read` for those formats — `Read` returns their raw bytes, which are unusable. Everything else (source code, plain text, JSON, Markdown) still goes through `Read`. + +Conversion is local; nothing is uploaded. Large documents are truncated to fit the message, keeping the beginning. diff --git a/packages/agent-core-v2/src/agent/tools/os/readDocument/readDocument.ts b/packages/agent-core-v2/src/agent/tools/os/readDocument/readDocument.ts new file mode 100644 index 00000000000..2bda4f2268b --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/readDocument/readDocument.ts @@ -0,0 +1,42 @@ +/** + * `tools` domain (L7) — `ReadDocument` contract. + * + * `Read` returns bytes, which is useless for a Word file or a PDF. This + * converts those formats to Markdown so the model can actually read them. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +/** Extensions anydoc recognises; used to advertise support without probing. */ +export const READ_DOCUMENT_EXTENSIONS = [ + 'pdf', + 'docx', + 'doc', + 'pptx', + 'ppt', + 'xlsx', + 'xls', + 'odt', + 'odp', + 'ods', + 'rtf', + 'epub', + 'csv', +] as const; + +export const ReadDocumentInputSchema = z.object({ + path: z + .string() + .trim() + .min(1) + .describe('Path to the document. Relative paths resolve against the working directory.'), +}); + +export type ReadDocumentInput = z.infer; + +export interface IReadDocumentTool extends AgentTool {} + +export const IReadDocumentTool = createDecorator('readDocumentTool'); diff --git a/packages/agent-core-v2/src/agent/tools/os/readDocument/readDocumentTool.ts b/packages/agent-core-v2/src/agent/tools/os/readDocument/readDocumentTool.ts new file mode 100644 index 00000000000..f42c48bdd0b --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/readDocument/readDocumentTool.ts @@ -0,0 +1,123 @@ +/** + * `tools` domain (L7) — `ReadDocument` implementation. + * + * Converts document formats to Markdown through `@firecrawl/anydoc`, a local + * Rust converter with prebuilt binaries. The import is lazy and failure is + * reported as a tool error rather than thrown: no prebuild exists for Windows + * on ARM, and a missing optional platform package must degrade to a clear + * message instead of breaking the agent. + * + * Path access goes through the same workspace resolution as `Read`, so this + * cannot reach outside the workspace. Bound at Agent scope. + */ + +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; +import { literalRulePattern } from '#/tool/rule-match'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { resolvePathAccessPath } from '#/tool/path-access'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { ToolResultBuilder } from '#/tool/result-builder'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { ToolAccesses, type ToolExecution } from '#/tool/toolContract'; + +import DESCRIPTION from './read-document.md?raw'; +import { + IReadDocumentTool, + ReadDocumentInputSchema, + READ_DOCUMENT_EXTENSIONS, + type ReadDocumentInput, +} from './readDocument'; + +type AnydocModule = { + toMarkdown: (path: string) => Promise; + formatFromPath: (path: string) => string | null; +}; + +let anydoc: Promise | undefined; + +/** Loaded once and cached, including the failure, so a missing binary is not retried per call. */ +async function loadAnydoc(): Promise { + anydoc ??= import('@firecrawl/anydoc') + .then((mod) => mod as unknown as AnydocModule) + .catch(() => undefined); + return anydoc; +} + +export function extensionOf(path: string): string { + const base = path.slice(path.lastIndexOf('/') + 1); + const dot = base.lastIndexOf('.'); + return dot <= 0 ? '' : base.slice(dot + 1).toLowerCase(); +} + +export function isSupportedDocument(path: string): boolean { + return (READ_DOCUMENT_EXTENSIONS as readonly string[]).includes(extensionOf(path)); +} + +export class ReadDocumentTool implements IReadDocumentTool { + declare readonly _serviceBrand: undefined; + readonly name = 'ReadDocument' as const; + readonly description = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(ReadDocumentInputSchema); + + constructor( + @IHostEnvironment private readonly env: IHostEnvironment, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + ) {} + + resolveExecution(args: ReadDocumentInput): ToolExecution { + const path = resolvePathAccessPath(args.path, { + env: this.env, + workspace: { + workspaceDir: this.workspaceCtx.workDir, + additionalDirs: this.workspaceCtx.additionalDirs, + }, + operation: 'read', + }); + + return { + accesses: ToolAccesses.readFile(path), + description: `Reading ${args.path} as Markdown`, + display: { kind: 'file_io', operation: 'read', path }, + approvalRule: literalRulePattern(this.name, path), + execute: async () => { + if (!isSupportedDocument(path)) { + return { + isError: true, + output: + `ReadDocument does not handle "${extensionOf(path) || 'this file'}". ` + + `Supported: ${READ_DOCUMENT_EXTENSIONS.join(', ')}. Use Read for text files.`, + }; + } + + const mod = await loadAnydoc(); + if (mod === undefined) { + return { + isError: true, + output: + 'Document conversion is unavailable on this platform, so this file cannot be read. ' + + 'Ask the user to convert it to Markdown or plain text.', + }; + } + + let markdown: string; + try { + markdown = await mod.toMarkdown(path); + } catch (error) { + return { + isError: true, + output: `Could not read ${args.path}: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + const builder = new ToolResultBuilder(); + builder.write(markdown); + return builder.ok(''); + }, + }; + } +} + +registerAgentToolService(IReadDocumentTool, ReadDocumentTool, { + name: 'ReadDocument', + domain: 'os/backends', +}); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 4f80347bd5f..77d267b0994 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -50,6 +50,8 @@ import '#/agent/tools/os/glob/globTool'; export * from '#/agent/tools/os/grep/grep'; import '#/agent/tools/os/grep/grepTool'; export * from '#/agent/tools/os/read/read'; +export * from '#/agent/tools/os/readDocument/readDocument'; +export * from '#/agent/tools/os/readDocument/readDocumentTool'; import '#/agent/tools/os/read/readTool'; export * from '#/agent/tools/os/write/write'; import '#/agent/tools/os/write/writeTool'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index 8730b6f494f..4b978ecaf17 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -25,6 +25,7 @@ import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md?raw'; const AGENT_TOOLS = [ 'Read', + 'ReadDocument', 'Write', 'Edit', 'Grep', @@ -69,6 +70,7 @@ const CODER_TOOLS = [ 'Glob', 'Grep', 'Read', + 'ReadDocument', 'ReadMediaFile', 'Skill', 'TaskList', @@ -83,6 +85,7 @@ const CODER_TOOLS = [ const EXPLORE_TOOLS = [ 'Read', + 'ReadDocument', 'ReadMediaFile', 'Glob', 'Grep', diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index de1c10dc6be..804bf3ff0a0 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -126,8 +126,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "