diff --git a/docs/configuration.md b/docs/configuration.md index 4c02eb1a..4c4cf5c8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,6 +38,7 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com | `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. | | `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. | | `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. | +| `DEVSPACE_INLINE_OUTPUT_CHARACTERS` | Maximum characters returned inline for text-heavy tool output. Defaults to `12000`; larger results use a bounded head/tail preview. | ## OAuth @@ -77,6 +78,31 @@ Codex-mode commands run without a PTY by default. Set `tty: true` on `node-pty` dependency; `write_stdin` can send input, poll output, and resize PTY sessions. +## Inline Output Limits + +Text-heavy tools such as `read`, `grep`, `glob`, `ls`, `bash`, `exec_command`, and +`write_stdin` return one bounded model-readable preview instead of copying the complete +output into every MCP response channel. + +Configure the ceiling through the environment: + +```bash +DEVSPACE_INLINE_OUTPUT_CHARACTERS=12000 npx @waishnav/devspace serve +``` + +or persist it in `~/.devspace/config.json`: + +```json +{ + "inlineOutputCharacters": 12000 +} +``` + +The result metadata reports the original character and line counts, the inline preview +size, and whether characters were omitted. Narrow the command or request a smaller file +range when more detail is needed. When widgets are set to `changes` or `off`, text-heavy +tools also omit their card payload instead of sending an unused second preview to the UI. + ## Widgets `DEVSPACE_WIDGETS` controls ChatGPT Apps iframe usage. diff --git a/package.json b/package.json index 25f21059..442f64b6 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/tool-output.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/cli.ts b/src/cli.ts index 7a1ac63f..deda3fbd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -161,6 +161,7 @@ async function runInit({ force }: { force: boolean }): Promise { port, allowedRoots, publicBaseUrl, + inlineOutputCharacters: files.config.inlineOutputCharacters, subagents: resolveSubagentsFlag(files.config), }; const auth = { @@ -219,6 +220,7 @@ async function serve(): Promise { console.log(`public base url: ${config.publicBaseUrl}`); console.log(`allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`); + console.log(`inline output characters: ${config.inlineOutputCharacters}`); if (config.allowedHosts.includes("*")) { console.warn("warning: Host header allowlist is disabled because DEVSPACE_ALLOWED_HOSTS=*"); } @@ -264,6 +266,7 @@ async function runDoctor(): Promise { console.log(`Public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`); console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`); console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`); + console.log(`Inline output characters: ${config.inlineOutputCharacters}`); } catch (error) { console.log(`Config status: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/config.test.ts b/src/config.test.ts index 0b4f99a8..99fce20f 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -16,6 +16,11 @@ assert.equal(loadConfig(baseEnv).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); +assert.equal(loadConfig(baseEnv).inlineOutputCharacters, 12_000); +assert.equal( + loadConfig({ ...baseEnv, DEVSPACE_INLINE_OUTPUT_CHARACTERS: "4096" }).inlineOutputCharacters, + 4096, +); assert.equal(loadConfig(baseEnv).toolMode, "minimal"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "minimal" }).toolMode, "minimal"); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).toolMode, "full"); @@ -60,6 +65,10 @@ assert.throws( () => loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "invalid" }), /Invalid DEVSPACE_TOOL_MODE: invalid/, ); +assert.throws( + () => loadConfig({ ...baseEnv, DEVSPACE_INLINE_OUTPUT_CHARACTERS: "0" }), + /Invalid DEVSPACE_INLINE_OUTPUT_CHARACTERS: 0/, +); assert.deepEqual(loadConfig(baseEnv).logging, { level: "info", @@ -162,6 +171,7 @@ writeFileSync( port: 8787, allowedRoots: [process.cwd()], publicBaseUrl: "https://devspace.example.com", + inlineOutputCharacters: 8192, subagents: true, }), ); @@ -176,6 +186,7 @@ const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir }); assert.equal(fileConfig.port, 8787); assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough"); assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com"); +assert.equal(fileConfig.inlineOutputCharacters, 8192); assert.equal(fileConfig.subagents, true); assert.deepEqual(fileConfig.allowedHosts, [ "localhost", diff --git a/src/config.ts b/src/config.ts index 4fc1bcbb..150a92d4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ import { join, resolve } from "node:path"; import { expandHomePath } from "./roots.js"; import type { LoggingConfig, LogFormat, LogLevel } from "./logger.js"; import type { OAuthConfig } from "./oauth-provider.js"; +import { DEFAULT_INLINE_OUTPUT_CHARACTERS } from "./tool-output.js"; import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user-config.js"; export type ToolMode = "minimal" | "full" | "codex"; @@ -19,6 +20,7 @@ export interface ServerConfig { publicBaseUrl: string; toolMode: ToolMode; widgets: WidgetMode; + inlineOutputCharacters: number; stateDir: string; worktreeRoot: string; skillsEnabled: boolean; @@ -124,8 +126,8 @@ function parseStringList(value: string | undefined, fallback: string[]): string[ return entries && entries.length > 0 ? entries : fallback; } -function parsePositiveInteger(value: string | undefined, fallback: number, name: string): number { - if (!value) return fallback; +function parsePositiveInteger(value: string | number | undefined, fallback: number, name: string): number { + if (value === undefined || value === "") return fallback; const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1) { @@ -224,6 +226,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { publicBaseUrl, toolMode: parseToolMode(env), widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), + inlineOutputCharacters: parsePositiveInteger( + env.DEVSPACE_INLINE_OUTPUT_CHARACTERS ?? files.config.inlineOutputCharacters, + DEFAULT_INLINE_OUTPUT_CHARACTERS, + "DEVSPACE_INLINE_OUTPUT_CHARACTERS", + ), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS), diff --git a/src/server.ts b/src/server.ts index 7dd9629e..6bfeff8a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -44,6 +44,12 @@ import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions. import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; +import { + createOutputPreview, + outputMetadata, + outputReceiptText, + type OutputPreview, +} from "./tool-output.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; import { summarizeLocalAgentProfile } from "./local-agent-profiles.js"; @@ -155,6 +161,17 @@ function toolWidgetDescriptorMeta( }; } +function toolResultMeta( + config: ServerConfig, + kind: ToolWidgetKind, + tool: string, + card: Record, +): Record { + return shouldAttachWidget(config.widgets, kind) + ? { tool, card } + : { tool }; +} + const toolNames = { openWorkspace: "open_workspace", read: "read", @@ -232,6 +249,18 @@ function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { }; } +function boundedTextOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { + return resultOutputSchema({ + outputReceipt: z.string(), + outputCharacters: z.number().int().nonnegative(), + outputLines: z.number().int().nonnegative(), + inlineOutputCharacters: z.number().int().nonnegative(), + inlineOutputTruncated: z.boolean(), + omittedOutputCharacters: z.number().int().nonnegative(), + ...extra, + }); +} + const workspaceSkillOutputSchema = z.object({ name: z.string(), description: z.string(), @@ -344,6 +373,44 @@ function textBlock(text: string): ToolContent { return { type: "text", text }; } +function boundedToolContent( + content: ToolContent[], + maxCharacters: number, +): { content: ToolContent[]; preview: OutputPreview } { + const preview = createOutputPreview(contentText(content), maxCharacters); + const nonTextContent = content.filter((item) => item.type !== "text"); + return { + content: [textBlock(preview.text), ...nonTextContent], + preview, + }; +} + +function boundedSummary(preview: OutputPreview): Record { + return { + lines: preview.originalLines, + characters: preview.originalCharacters, + inlineCharacters: preview.inlineCharacters, + inlineTruncated: preview.truncated, + omittedCharacters: preview.omittedCharacters, + }; +} + +function boundResponseContent( + response: T, + maxCharacters: number, +): T { + const bounded = boundedToolContent(response.content, maxCharacters); + return { ...response, content: bounded.content }; +} + +function boundedStructuredContent(preview: OutputPreview, status?: string) { + return { + result: preview.text, + outputReceipt: outputReceiptText(preview, status), + ...outputMetadata(preview), + }; +} + function textSummary(content: ToolContent[]): { lines: number; characters: number; @@ -487,17 +554,21 @@ async function assertWorkspaceAppAssets(): Promise { } } -function processResult(snapshot: ProcessSnapshot): string { - const status = snapshot.running +function processStatus(snapshot: ProcessSnapshot): string { + return snapshot.running ? `Process running with session ID ${snapshot.sessionId}.` : snapshot.signal ? `Process exited after signal ${snapshot.signal}.` : `Process exited with code ${snapshot.exitCode ?? "unknown"}.`; +} + +function processResult(snapshot: ProcessSnapshot): string { + const status = processStatus(snapshot); return snapshot.output ? `${snapshot.output.replace(/\n$/, "")}\n${status}` : status; } function processOutputSchema(): z.ZodRawShape { - return resultOutputSchema({ + return boundedTextOutputSchema({ sessionId: z.number().optional(), running: z.boolean(), exitCode: z.number().int().optional(), @@ -508,26 +579,25 @@ function processOutputSchema(): z.ZodRawShape { } function processToolResponse( + config: ServerConfig, tool: "exec_command" | "write_stdin", workspaceId: string, snapshot: ProcessSnapshot, summary: Record, ) { - const result = processResult(snapshot); - const content = [textBlock(result)]; - const outputSummary = textSummary(snapshot.output ? [textBlock(snapshot.output)] : []); + const bounded = boundedToolContent( + [textBlock(processResult(snapshot))], + config.inlineOutputCharacters, + ); return { - content, - _meta: { - tool, - card: { - workspaceId, - summary: { ...summary, ...outputSummary }, - payload: { content }, - }, - }, + content: bounded.content, + _meta: toolResultMeta(config, "shell", tool, { + workspaceId, + summary: { ...summary, ...boundedSummary(bounded.preview) }, + payload: { content: bounded.content }, + }), structuredContent: { - result, + ...boundedStructuredContent(bounded.preview, processStatus(snapshot)), sessionId: snapshot.sessionId, running: snapshot.running, exitCode: snapshot.exitCode, @@ -609,7 +679,7 @@ function registerCodexProcessTools( durationMs: Math.round(performance.now() - startedAt), }); - return processToolResponse("exec_command", workspaceId, snapshot, { + return processToolResponse(config, "exec_command", workspaceId, snapshot, { command: cmd, workingDirectory: workingDirectory ?? ".", running: snapshot.running, @@ -671,7 +741,7 @@ function registerCodexProcessTools( durationMs: Math.round(performance.now() - startedAt), }); - return processToolResponse("write_stdin", workspaceId, snapshot, { + return processToolResponse(config, "write_stdin", workspaceId, snapshot, { sessionId, charactersWritten: chars?.length ?? 0, running: snapshot.running, @@ -932,7 +1002,7 @@ function createMcpServer( .optional() .describe("Maximum number of lines to read."), }, - outputSchema: resultOutputSchema(), + outputSchema: boundedTextOutputSchema(), ...toolWidgetDescriptorMeta(config, "read"), annotations: { readOnlyHint: true }, }, @@ -955,12 +1025,16 @@ function createMcpServer( workspaceId, path: input.path, }, response.content, startedAt); - return response; + return boundResponseContent(response, config.inlineOutputCharacters); } workspaces.markReadPathLoaded(workspace, readPath); + const bounded = boundedToolContent( + response.content, + config.inlineOutputCharacters, + ); const summary = { - ...textSummary(response.content), + ...boundedSummary(bounded.preview), offset: input.offset ?? 1, limited: input.limit !== undefined, }; @@ -974,18 +1048,14 @@ function createMcpServer( return { ...response, - _meta: { - tool: toolNames.read, - card: { - workspaceId, - path: input.path, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, + content: bounded.content, + _meta: toolResultMeta(config, "read", toolNames.read, { + workspaceId, + path: input.path, + summary, + payload: { content: bounded.content }, + }), + structuredContent: boundedStructuredContent(bounded.preview), }; }, ); @@ -1308,7 +1378,7 @@ function createMcpServer( ), include: z.string().optional().describe("Optional include glob."), }, - outputSchema: resultOutputSchema(), + outputSchema: boundedTextOutputSchema(), ...toolWidgetDescriptorMeta(config, "search"), annotations: { readOnlyHint: true }, }, @@ -1327,13 +1397,17 @@ function createMcpServer( workspaceId, path: input.path, }, response.content, startedAt); - return response; + return boundResponseContent(response, config.inlineOutputCharacters); } + const bounded = boundedToolContent( + response.content, + config.inlineOutputCharacters, + ); const summary = { pattern: input.pattern, scope: input.path ?? ".", - ...textSummary(response.content), + ...boundedSummary(bounded.preview), }; logToolCall(config, { tool: toolNames.grep, @@ -1345,18 +1419,14 @@ function createMcpServer( return { ...response, - _meta: { - tool: toolNames.grep, - card: { - workspaceId, - path: input.path, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, + content: bounded.content, + _meta: toolResultMeta(config, "search", toolNames.grep, { + workspaceId, + path: input.path, + summary, + payload: { content: bounded.content }, + }), + structuredContent: boundedStructuredContent(bounded.preview), }; }, ); @@ -1378,7 +1448,7 @@ function createMcpServer( .optional() .describe("Optional path scope relative to the workspace root."), }, - outputSchema: resultOutputSchema(), + outputSchema: boundedTextOutputSchema(), ...toolWidgetDescriptorMeta(config, "search"), annotations: { readOnlyHint: true }, }, @@ -1397,13 +1467,17 @@ function createMcpServer( workspaceId, path: input.path, }, response.content, startedAt); - return response; + return boundResponseContent(response, config.inlineOutputCharacters); } + const bounded = boundedToolContent( + response.content, + config.inlineOutputCharacters, + ); const summary = { pattern: input.pattern, scope: input.path ?? ".", - ...textSummary(response.content), + ...boundedSummary(bounded.preview), }; logToolCall(config, { tool: toolNames.glob, @@ -1415,18 +1489,14 @@ function createMcpServer( return { ...response, - _meta: { - tool: toolNames.glob, - card: { - workspaceId, - path: input.path, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, + content: bounded.content, + _meta: toolResultMeta(config, "search", toolNames.glob, { + workspaceId, + path: input.path, + summary, + payload: { content: bounded.content }, + }), + structuredContent: boundedStructuredContent(bounded.preview), }; }, ); @@ -1448,7 +1518,7 @@ function createMcpServer( "Directory path to list, relative to the workspace root.", ), }, - outputSchema: resultOutputSchema(), + outputSchema: boundedTextOutputSchema(), ...toolWidgetDescriptorMeta(config, "directory"), annotations: { readOnlyHint: true }, }, @@ -1467,10 +1537,14 @@ function createMcpServer( workspaceId, path: input.path, }, response.content, startedAt); - return response; + return boundResponseContent(response, config.inlineOutputCharacters); } - const summary = textSummary(response.content); + const bounded = boundedToolContent( + response.content, + config.inlineOutputCharacters, + ); + const summary = boundedSummary(bounded.preview); logToolCall(config, { tool: toolNames.ls, workspaceId, @@ -1481,18 +1555,14 @@ function createMcpServer( return { ...response, - _meta: { - tool: toolNames.ls, - card: { - workspaceId, - path: input.path, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, + content: bounded.content, + _meta: toolResultMeta(config, "directory", toolNames.ls, { + workspaceId, + path: input.path, + summary, + payload: { content: bounded.content }, + }), + structuredContent: boundedStructuredContent(bounded.preview), }; }, ); @@ -1529,7 +1599,7 @@ function createMcpServer( .optional() .describe("Timeout in seconds. Defaults to 30, max 300."), }, - outputSchema: resultOutputSchema(), + outputSchema: boundedTextOutputSchema(), ...toolWidgetDescriptorMeta(config, "shell"), annotations: SHELL_TOOL_ANNOTATIONS, }, @@ -1553,13 +1623,17 @@ function createMcpServer( command: input.command, commandLength: input.command.length, }, response.content, startedAt); - return response; + return boundResponseContent(response, config.inlineOutputCharacters); } + const bounded = boundedToolContent( + response.content, + config.inlineOutputCharacters, + ); const summary = { command: input.command, workingDirectory: workingDirectory ?? ".", - ...textSummary(response.content), + ...boundedSummary(bounded.preview), }; logToolCall(config, { tool: toolNames.shell, @@ -1573,18 +1647,14 @@ function createMcpServer( return { ...response, - _meta: { - tool: toolNames.shell, - card: { - workspaceId, - path: workingDirectory, - summary, - payload: { content: response.content }, - }, - }, - structuredContent: { - result: contentText(response.content), - }, + content: bounded.content, + _meta: toolResultMeta(config, "shell", toolNames.shell, { + workspaceId, + path: workingDirectory, + summary, + payload: { content: bounded.content }, + }), + structuredContent: boundedStructuredContent(bounded.preview), }; }, ); diff --git a/src/tool-output.test.ts b/src/tool-output.test.ts new file mode 100644 index 00000000..6841ae4d --- /dev/null +++ b/src/tool-output.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { + DEFAULT_INLINE_OUTPUT_CHARACTERS, + createOutputPreview, + outputMetadata, + outputReceiptText, +} from "./tool-output.js"; + +assert.equal(DEFAULT_INLINE_OUTPUT_CHARACTERS, 12_000); + +const complete = createOutputPreview("alpha\nbeta\n", 100); +assert.deepEqual(complete, { + text: "alpha\nbeta\n", + originalCharacters: 11, + originalLines: 2, + inlineCharacters: 11, + omittedCharacters: 0, + truncated: false, +}); +assert.match(outputReceiptText(complete), /Complete output is included/); + +const large = "HEAD-" + "x".repeat(20_000) + "-TAIL"; +const preview = createOutputPreview(large, 1_000); +assert.equal(preview.truncated, true); +assert.equal(preview.originalCharacters, 20_010); +assert.equal(preview.inlineCharacters, 1_000); +assert.equal(Array.from(preview.text).length, 1_000); +assert.ok(preview.text.startsWith("HEAD-")); +assert.ok(preview.text.endsWith("-TAIL")); +assert.match(preview.text, /DevSpace inline preview truncated/); +assert.equal( + preview.omittedCharacters, + preview.originalCharacters - + (preview.inlineCharacters - Array.from("\n... DevSpace inline preview truncated; narrow the command or read a smaller range to retrieve more ...\n").length), +); +assert.match(outputReceiptText(preview, "Process exited with code 0."), /bounded head\/tail preview/); +assert.match(outputReceiptText(preview, "Process exited with code 0."), /Process exited with code 0/); +assert.deepEqual(outputMetadata(preview), { + outputCharacters: 20_010, + outputLines: 1, + inlineOutputCharacters: 1_000, + inlineOutputTruncated: true, + omittedOutputCharacters: preview.omittedCharacters, +}); + +const unicode = createOutputPreview("🙂".repeat(50), 20); +assert.equal(unicode.originalCharacters, 50); +assert.equal(unicode.inlineCharacters, 20); +assert.equal(Array.from(unicode.text).length, 20); +assert.equal(unicode.truncated, true); + +const rawOutput = "z".repeat(50_000); +const boundedPayloadPreview = createOutputPreview(rawOutput, 12_000); +const oldPayloadSize = JSON.stringify({ + content: [{ type: "text", text: rawOutput }], + structuredContent: { result: rawOutput }, + _meta: { card: { payload: { content: [{ type: "text", text: rawOutput }] } } }, +}).length; +const newPayloadSize = JSON.stringify({ + content: [{ type: "text", text: boundedPayloadPreview.text }], + structuredContent: { + result: outputReceiptText(boundedPayloadPreview), + ...outputMetadata(boundedPayloadPreview), + }, + _meta: { + card: { + payload: { content: [{ type: "text", text: boundedPayloadPreview.text }] }, + }, + }, +}).length; +assert.ok(oldPayloadSize > 150_000); +assert.ok(newPayloadSize < 30_000); +assert.ok(newPayloadSize < oldPayloadSize / 4); + +assert.throws( + () => createOutputPreview("output", 0), + /Inline output limit must be a positive integer/, +); diff --git a/src/tool-output.ts b/src/tool-output.ts new file mode 100644 index 00000000..fa8f24d7 --- /dev/null +++ b/src/tool-output.ts @@ -0,0 +1,128 @@ +export const DEFAULT_INLINE_OUTPUT_CHARACTERS = 12_000; + +export interface OutputPreview { + text: string; + originalCharacters: number; + originalLines: number; + inlineCharacters: number; + omittedCharacters: number; + truncated: boolean; +} + +const TRUNCATION_MARKER = + "\n... DevSpace inline preview truncated; narrow the command or read a smaller range to retrieve more ...\n"; + +function codePoints(value: string): string[] { + return Array.from(value); +} + +function characterLength(value: string): number { + return codePoints(value).length; +} + +function lineCount(value: string): number { + if (value.length === 0) return 0; + return value.endsWith("\n") + ? value.slice(0, -1).split("\n").length + : value.split("\n").length; +} + +function takeHead(value: string, count: number): string { + if (count <= 0) return ""; + return codePoints(value).slice(0, count).join(""); +} + +function takeTail(value: string, count: number): string { + if (count <= 0) return ""; + const characters = codePoints(value); + return characters.slice(Math.max(0, characters.length - count)).join(""); +} + +export function createOutputPreview( + value: string, + maxCharacters = DEFAULT_INLINE_OUTPUT_CHARACTERS, +): OutputPreview { + if (!Number.isInteger(maxCharacters) || maxCharacters < 1) { + throw new Error("Inline output limit must be a positive integer."); + } + + const originalCharacters = characterLength(value); + const originalLines = lineCount(value); + + if (originalCharacters <= maxCharacters) { + return { + text: value, + originalCharacters, + originalLines, + inlineCharacters: originalCharacters, + omittedCharacters: 0, + truncated: false, + }; + } + + const markerCharacters = characterLength(TRUNCATION_MARKER); + if (maxCharacters <= markerCharacters) { + const text = takeHead(value, maxCharacters); + return { + text, + originalCharacters, + originalLines, + inlineCharacters: characterLength(text), + omittedCharacters: originalCharacters - characterLength(text), + truncated: true, + }; + } + + const available = maxCharacters - markerCharacters; + const headCharacters = Math.ceil(available * 0.65); + const tailCharacters = available - headCharacters; + const text = + takeHead(value, headCharacters) + + TRUNCATION_MARKER + + takeTail(value, tailCharacters); + const inlineCharacters = characterLength(text); + + return { + text, + originalCharacters, + originalLines, + inlineCharacters, + omittedCharacters: originalCharacters - headCharacters - tailCharacters, + truncated: true, + }; +} + +export function outputReceiptText( + preview: OutputPreview, + status?: string, +): string { + const prefix = status ? `${status} ` : ""; + if (!preview.truncated) { + return ( + `${prefix}Complete output is included in content ` + + `(${preview.originalCharacters} characters, ${preview.originalLines} lines).` + ); + } + + return ( + `${prefix}Content contains a bounded head/tail preview ` + + `(${preview.inlineCharacters} of ${preview.originalCharacters} characters, ` + + `${preview.originalLines} original lines; ${preview.omittedCharacters} characters omitted).` + ); +} + +export function outputMetadata(preview: OutputPreview): { + outputCharacters: number; + outputLines: number; + inlineOutputCharacters: number; + inlineOutputTruncated: boolean; + omittedOutputCharacters: number; +} { + return { + outputCharacters: preview.originalCharacters, + outputLines: preview.originalLines, + inlineOutputCharacters: preview.inlineCharacters, + inlineOutputTruncated: preview.truncated, + omittedOutputCharacters: preview.omittedCharacters, + }; +} diff --git a/src/user-config.ts b/src/user-config.ts index c8da90b5..34249b9c 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -15,6 +15,7 @@ export interface DevspaceUserConfig { allowedRoots?: string[]; publicBaseUrl?: string | null; allowedHosts?: string[]; + inlineOutputCharacters?: number; stateDir?: string; worktreeRoot?: string; agentDir?: string;