From 4cc6f5e11e2a15a2da13a15a9b735ab8b035f9aa Mon Sep 17 00:00:00 2001 From: Theodore Blackman Date: Tue, 24 Feb 2026 20:49:45 -0800 Subject: [PATCH 01/18] fix: prevent LCM inline content labels from being treated as file paths When large tool outputs were stored inline in LCM, metadata labels like "tool_output_tasks_toolu_*" were saved as original_path in the database. This caused the AI to attempt reading them as files, resolving against the project CWD and producing "File not found" errors. Set original_path to null for inline content, clarify lcm_describe output for inline entries, and remove misleading Read tool instructions. Co-Authored-By: Claude Opus 4.6 --- packages/voltcode/src/session/large-tool-output.ts | 2 +- packages/voltcode/src/session/lcm/db.ts | 7 ++++--- packages/voltcode/src/tool/lcm-describe.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/voltcode/src/session/large-tool-output.ts b/packages/voltcode/src/session/large-tool-output.ts index 7d31b95ed..3078d6922 100644 --- a/packages/voltcode/src/session/large-tool-output.ts +++ b/packages/voltcode/src/session/large-tool-output.ts @@ -111,7 +111,7 @@ export async function handleLargeToolOutput(input: { preview, hasMore ? `\n...[${tokenCount - Token.estimate(preview)} more tokens]` : "", ``, - `To access the full output, use the Read tool with the original file path, or use lcm_describe with file_id "${fileId}" to see metadata about this stored output.`, + `The full output is stored in LCM. Use lcm_describe with file_id "${fileId}" to see metadata. Do NOT attempt to read this content with the Read tool — it is stored in the LCM database, not as a file on disk.`, ].join("\n") return { diff --git a/packages/voltcode/src/session/lcm/db.ts b/packages/voltcode/src/session/lcm/db.ts index 4e770c536..8a0b9b25c 100644 --- a/packages/voltcode/src/session/lcm/db.ts +++ b/packages/voltcode/src/session/lcm/db.ts @@ -1724,8 +1724,9 @@ export namespace LcmDb { const tokenCount = LargeFileThreshold.estimateTokenCount(input.content) const fileId = generateFileId(input.conversationId, input.content) - // Use the label as original_path for identification (e.g., "user_prompt_12345") - const originalPath = input.label ?? `inline_content_${Date.now()}` + // Don't store labels as original_path — that field is for actual file paths on disk. + // Inline content is stored in the content column and read from there. + const originalPath = null await conn` INSERT INTO large_files (file_id, conversation_id, original_path, mime_type, content, binary_content, token_count) @@ -1735,7 +1736,7 @@ export namespace LcmDb { log.debug("inserted large text content", { fileId, conversationId: input.conversationId, - label: originalPath, + label: input.label, tokenCount, contentLength: input.content.length, }) diff --git a/packages/voltcode/src/tool/lcm-describe.ts b/packages/voltcode/src/tool/lcm-describe.ts index 20ed456cc..10be4b0b2 100644 --- a/packages/voltcode/src/tool/lcm-describe.ts +++ b/packages/voltcode/src/tool/lcm-describe.ts @@ -66,7 +66,7 @@ async function describeFile(fileId: string, sessionID: string) { const lines: string[] = [] lines.push(`## LCM File: ${fileId}`) lines.push("") - lines.push(`**Path:** ${file.original_path ?? "(no path)"}`) + lines.push(`**Path:** ${file.original_path ?? "(inline content — stored in LCM database, not on disk)"}`) lines.push(`**Type:** ${file.mime_type}`) lines.push(`**Tokens:** ~${file.token_count.toLocaleString()}`) lines.push(`**Created:** ${file.created_at.toISOString()}`) From 6be6194d06ae1b9b6f8768abaa10f40622a360dd Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 12:49:58 -0800 Subject: [PATCH 02/18] fix: make inline LCM payloads first-class storage kinds - Add a storage_kind model for large_files (path, inline_text, inline_binary) and migrate existing rows safely. - Relax original_path nullability for inline payloads and enforce row shape via a storage constraint. - Update insert/retrieval paths to write and read by storage_kind instead of fake path semantics. - Improve lcm_describe output to show storage mode explicitly and avoid path confusion. - Update large-user-text integration expectations for inline storage metadata. --- packages/voltcode/src/session/lcm/db.ts | 112 ++++++++++++++---- .../voltcode/src/session/lcm/large-file.ts | 7 +- .../voltcode/src/session/lcm/user-context.ts | 10 +- packages/voltcode/src/tool/lcm-describe.ts | 11 +- .../test/session/lcm/large-user-text.test.ts | 3 +- 5 files changed, 111 insertions(+), 32 deletions(-) diff --git a/packages/voltcode/src/session/lcm/db.ts b/packages/voltcode/src/session/lcm/db.ts index 8a0b9b25c..aa4cef5ce 100644 --- a/packages/voltcode/src/session/lcm/db.ts +++ b/packages/voltcode/src/session/lcm/db.ts @@ -108,6 +108,7 @@ export namespace LcmDb { export const LargeFile = z.object({ file_id: z.string(), conversation_id: z.number(), + storage_kind: z.enum(["path", "inline_text", "inline_binary"]), original_path: z.string().nullable(), mime_type: z.string(), content: z.string().nullable(), @@ -473,17 +474,17 @@ export namespace LcmDb { CREATE INDEX IF NOT EXISTS ctx_items_message_idx ON context_items(message_id); -- 7) Large files (for files too big to fit in context) - -- Stores path references to files on disk; content is read on demand + -- Stores path-backed files and inline payloads (text/binary) under one ID space. CREATE TABLE IF NOT EXISTS large_files ( file_id text PRIMARY KEY, conversation_id bigint NOT NULL REFERENCES conversations(conversation_id) ON DELETE CASCADE, - original_path text NOT NULL, -- Path to the file on disk (required for path-based storage) + storage_kind text NOT NULL DEFAULT 'path', -- path | inline_text | inline_binary + original_path text, -- Path to the file on disk (for storage_kind='path') mime_type text NOT NULL, - content text, -- Legacy: kept for backwards compatibility, not used for new files - binary_content bytea, -- Legacy: kept for backwards compatibility, not used for new files + content text, -- Inline text payload + binary_content bytea, -- Inline binary payload token_count bigint NOT NULL, -- BIGINT to support files with billions of tokens created_at timestamptz NOT NULL DEFAULT now() - -- Removed content check constraint to allow path-only storage ); -- Migration: change token_count from integer to bigint if needed @@ -503,8 +504,52 @@ export namespace LcmDb { END IF; END $$; - -- Migration: make original_path NOT NULL for new records (existing NULL paths grandfathered) - -- Note: Can't add NOT NULL constraint if existing rows have NULL, so we skip this + -- Migration: add storage_kind for explicit payload mode (path/inline_text/inline_binary) + DO $$ BEGIN + ALTER TABLE large_files ADD COLUMN storage_kind text; + EXCEPTION WHEN duplicate_column THEN NULL; END $$; + + -- Migration: backfill storage_kind from existing data + UPDATE large_files + SET storage_kind = CASE + WHEN content IS NOT NULL THEN 'inline_text' + WHEN binary_content IS NOT NULL THEN 'inline_binary' + ELSE 'path' + END + WHERE storage_kind IS NULL; + + -- Migration: default + NOT NULL for storage_kind + DO $$ BEGIN + ALTER TABLE large_files ALTER COLUMN storage_kind SET DEFAULT 'path'; + EXCEPTION WHEN others THEN NULL; END $$; + DO $$ BEGIN + ALTER TABLE large_files ALTER COLUMN storage_kind SET NOT NULL; + EXCEPTION WHEN others THEN NULL; END $$; + + -- Migration: original_path is optional for inline payloads + DO $$ BEGIN + ALTER TABLE large_files ALTER COLUMN original_path DROP NOT NULL; + EXCEPTION WHEN others THEN NULL; END $$; + + -- Migration: enforce coherent large_files row shape by storage_kind + DO $$ BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'large_files_storage_shape_check' + AND conrelid = 'large_files'::regclass + ) THEN + EXECUTE 'ALTER TABLE large_files DROP CONSTRAINT large_files_storage_shape_check'; + END IF; + END $$; + DO $$ BEGIN + ALTER TABLE large_files + ADD CONSTRAINT large_files_storage_shape_check CHECK ( + (storage_kind = 'path' AND original_path IS NOT NULL AND content IS NULL AND binary_content IS NULL) OR + (storage_kind = 'inline_text' AND content IS NOT NULL AND binary_content IS NULL) OR + (storage_kind = 'inline_binary' AND binary_content IS NOT NULL AND content IS NULL) + ); + EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE INDEX IF NOT EXISTS large_files_conv_idx ON large_files(conversation_id); CREATE INDEX IF NOT EXISTS large_files_path_idx ON large_files(original_path); @@ -1693,8 +1738,8 @@ export namespace LcmDb { const fileId = generateFileId(input.conversationId, input.content) await conn` - INSERT INTO large_files (file_id, conversation_id, original_path, mime_type, content, token_count) - VALUES (${fileId}, ${input.conversationId}, ${input.originalPath ?? null}, ${input.mimeType}, ${escNull(input.content)}, ${input.tokenCount}) + INSERT INTO large_files (file_id, conversation_id, storage_kind, original_path, mime_type, content, token_count) + VALUES (${fileId}, ${input.conversationId}, 'inline_text', ${input.originalPath ?? null}, ${input.mimeType}, ${escNull(input.content)}, ${input.tokenCount}) ON CONFLICT (file_id) DO NOTHING ` log.debug("inserted large file", { fileId, conversationId: input.conversationId, originalPath: input.originalPath }) @@ -1729,8 +1774,8 @@ export namespace LcmDb { const originalPath = null await conn` - INSERT INTO large_files (file_id, conversation_id, original_path, mime_type, content, binary_content, token_count) - VALUES (${fileId}, ${input.conversationId}, ${originalPath}, ${input.mimeType ?? "text/plain"}, ${escNull(input.content)}, NULL, ${tokenCount}) + INSERT INTO large_files (file_id, conversation_id, storage_kind, original_path, mime_type, content, binary_content, token_count) + VALUES (${fileId}, ${input.conversationId}, 'inline_text', ${originalPath}, ${input.mimeType ?? "text/plain"}, ${escNull(input.content)}, NULL, ${tokenCount}) ON CONFLICT (file_id) DO NOTHING ` log.debug("inserted large text content", { @@ -1780,8 +1825,8 @@ export namespace LcmDb { // Store only the path reference, never the content // Convert bigint to string for postgres since it handles numeric types correctly await conn` - INSERT INTO large_files (file_id, conversation_id, original_path, mime_type, content, binary_content, token_count) - VALUES (${fileId}, ${input.conversationId}, ${input.filePath}, ${input.mimeType}, NULL, NULL, ${tokenCount.toString()}) + INSERT INTO large_files (file_id, conversation_id, storage_kind, original_path, mime_type, content, binary_content, token_count) + VALUES (${fileId}, ${input.conversationId}, 'path', ${input.filePath}, ${input.mimeType}, NULL, NULL, ${tokenCount.toString()}) ON CONFLICT (file_id) DO NOTHING ` log.debug("inserted large file path reference", { @@ -1830,8 +1875,8 @@ export namespace LcmDb { const fileId = generateBinaryFileId(input.conversationId, input.binaryContent) await conn` - INSERT INTO large_files (file_id, conversation_id, original_path, mime_type, binary_content, token_count) - VALUES (${fileId}, ${input.conversationId}, ${input.originalPath ?? null}, ${input.mimeType}, ${input.binaryContent}, ${input.tokenCount}) + INSERT INTO large_files (file_id, conversation_id, storage_kind, original_path, mime_type, binary_content, token_count) + VALUES (${fileId}, ${input.conversationId}, 'inline_binary', ${input.originalPath ?? null}, ${input.mimeType}, ${input.binaryContent}, ${input.tokenCount}) ON CONFLICT (file_id) DO NOTHING ` log.debug("inserted large binary file", { @@ -1858,7 +1903,7 @@ export namespace LcmDb { // If no conversationId provided, just do a simple lookup (backwards compatible) if (conversationId === undefined) { const rows = await conn` - SELECT file_id, conversation_id, original_path, mime_type, content, binary_content, token_count, created_at, exploration_summary, explorer_used + SELECT file_id, conversation_id, storage_kind, original_path, mime_type, content, binary_content, token_count, created_at, exploration_summary, explorer_used FROM large_files WHERE file_id = ${fileId} ` @@ -1876,7 +1921,7 @@ export namespace LcmDb { FROM conversations c JOIN ancestors a ON c.conversation_id = a.parent_conversation_id ) - SELECT lf.file_id, lf.conversation_id, lf.original_path, lf.mime_type, lf.content, lf.binary_content, lf.token_count, lf.created_at + SELECT lf.file_id, lf.conversation_id, lf.storage_kind, lf.original_path, lf.mime_type, lf.content, lf.binary_content, lf.token_count, lf.created_at, lf.exploration_summary, lf.explorer_used FROM large_files lf JOIN ancestors a ON lf.conversation_id = a.conversation_id WHERE lf.file_id = ${fileId} @@ -1905,12 +1950,12 @@ export namespace LcmDb { // If no conversationId provided, just do a simple lookup (backwards compatible) const rows = conversationId === undefined - ? await conn<{ content: string | null; original_path: string | null }[]>` - SELECT content, original_path + ? await conn<{ storage_kind: string; content: string | null; original_path: string | null }[]>` + SELECT storage_kind, content, original_path FROM large_files WHERE file_id = ${fileId} ` - : await conn<{ content: string | null; original_path: string | null }[]>` + : await conn<{ storage_kind: string; content: string | null; original_path: string | null }[]>` WITH RECURSIVE ancestors AS ( SELECT conversation_id, parent_conversation_id FROM conversations @@ -1920,7 +1965,7 @@ export namespace LcmDb { FROM conversations c JOIN ancestors a ON c.conversation_id = a.parent_conversation_id ) - SELECT lf.content, lf.original_path + SELECT lf.storage_kind, lf.content, lf.original_path FROM large_files lf JOIN ancestors a ON lf.conversation_id = a.conversation_id WHERE lf.file_id = ${fileId} @@ -1928,8 +1973,11 @@ export namespace LcmDb { const row = rows[0] if (!row) return null - // If content is stored inline (legacy), return it - if (row.content !== null) { + // Inline text payloads are returned directly from the DB. + if (row.storage_kind === "inline_text" || (row.storage_kind !== "path" && row.content !== null)) { + if (row.content === null) { + return null + } const limit = maxBytes ?? row.content.length const truncated = row.content.length > limit return { @@ -1939,8 +1987,8 @@ export namespace LcmDb { } } - // Read content from disk using the stored path - if (row.original_path) { + // Path-backed payloads are loaded from disk on demand. + if (row.storage_kind === "path" && row.original_path) { const file = Bun.file(row.original_path) const exists = await file.exists() if (!exists) { @@ -1968,6 +2016,18 @@ export namespace LcmDb { return { content, truncated: false, totalSize } } + // Backward-compatibility fallback for rows that predate storage_kind enforcement. + if (row.original_path) { + const file = Bun.file(row.original_path) + const exists = await file.exists() + if (!exists) { + log.warn("legacy large file path not found on disk", { fileId, path: row.original_path }) + return null + } + const content = await file.text() + return { content, truncated: false, totalSize: content.length } + } + return null } @@ -1980,7 +2040,7 @@ export namespace LcmDb { export async function getLargeFilesByConversation(conversationId: number): Promise { const conn = sql() const rows = await conn` - SELECT file_id, conversation_id, original_path, mime_type, content, binary_content, token_count, created_at, exploration_summary, explorer_used + SELECT file_id, conversation_id, storage_kind, original_path, mime_type, content, binary_content, token_count, created_at, exploration_summary, explorer_used FROM large_files WHERE conversation_id = ${conversationId} ORDER BY created_at diff --git a/packages/voltcode/src/session/lcm/large-file.ts b/packages/voltcode/src/session/lcm/large-file.ts index a22752d0e..31853e620 100644 --- a/packages/voltcode/src/session/lcm/large-file.ts +++ b/packages/voltcode/src/session/lcm/large-file.ts @@ -23,6 +23,8 @@ export namespace LargeFile { conversationId: z.string(), /** Original file path (if available) */ originalPath: z.string().nullable(), + /** Storage mode for payload retrieval. */ + storageKind: z.enum(["path", "inline_text", "inline_binary"]).default("path"), /** MIME type of the file */ mimeType: z.string(), /** Estimated token count for the file content */ @@ -104,6 +106,7 @@ export namespace LargeFile { fileId: generateId(input.content), conversationId: input.conversationId, originalPath: input.originalPath ?? null, + storageKind: "inline_text", mimeType: input.mimeType, tokenCount: input.tokenCount, isBinary: false, @@ -124,6 +127,7 @@ export namespace LargeFile { fileId: generateId(input.binaryContent), conversationId: input.conversationId, originalPath: input.originalPath ?? null, + storageKind: "inline_binary", mimeType: input.mimeType, tokenCount: input.tokenCount, isBinary: true, @@ -150,7 +154,8 @@ export namespace LargeFile { export function formatForContext(file: Info): string { const lines: string[] = [] lines.push(`[Large File ID: ${file.fileId}]`) - if (file.originalPath) { + lines.push(`[Storage: ${file.storageKind}]`) + if (file.storageKind === "path" && file.originalPath) { lines.push(`[Path: ${file.originalPath}]`) } lines.push(`[Type: ${file.mimeType}]`) diff --git a/packages/voltcode/src/session/lcm/user-context.ts b/packages/voltcode/src/session/lcm/user-context.ts index 1f5c17a1d..27ca90045 100644 --- a/packages/voltcode/src/session/lcm/user-context.ts +++ b/packages/voltcode/src/session/lcm/user-context.ts @@ -221,14 +221,20 @@ export async function ensureUserSchema(conn: postgres.Sql, userId: string): Prom CREATE TABLE IF NOT EXISTS large_files ( file_id text PRIMARY KEY, conversation_id bigint NOT NULL REFERENCES conversations(conversation_id) ON DELETE CASCADE, - original_path text NOT NULL, + storage_kind text NOT NULL DEFAULT 'path', + original_path text, mime_type text NOT NULL, content text, binary_content bytea, token_count bigint NOT NULL, exploration_summary text, explorer_used text, - created_at timestamptz NOT NULL DEFAULT now() + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT large_files_storage_shape_check CHECK ( + (storage_kind = 'path' AND original_path IS NOT NULL AND content IS NULL AND binary_content IS NULL) OR + (storage_kind = 'inline_text' AND content IS NOT NULL AND binary_content IS NULL) OR + (storage_kind = 'inline_binary' AND binary_content IS NOT NULL AND content IS NULL) + ) ); CREATE INDEX IF NOT EXISTS large_files_conv_idx ON large_files(conversation_id); diff --git a/packages/voltcode/src/tool/lcm-describe.ts b/packages/voltcode/src/tool/lcm-describe.ts index 10be4b0b2..244393f27 100644 --- a/packages/voltcode/src/tool/lcm-describe.ts +++ b/packages/voltcode/src/tool/lcm-describe.ts @@ -42,6 +42,12 @@ export const LcmDescribeTool = Tool.define { expect(file).not.toBeNull() expect(file!.file_id).toBe(fileId) expect(file!.conversation_id).toBe(testConversationId) + expect(file!.storage_kind).toBe("inline_text") expect(file!.mime_type).toBe("text/plain") - expect(file!.original_path).toBe("test_metadata") + expect(file!.original_path).toBeNull() expect(file!.content).toBe(testContent) expect(Number(file!.token_count)).toBe(tokenCount) }) From 235703eb7048156b984e64bc2f97bf4f8d34b026 Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 13:51:31 -0800 Subject: [PATCH 03/18] feat: add lcm_read tool for retrieving stored LCM file content - Large tool outputs and pasted user text stored in LCM had no retrieval path exposed to the model. The model would try Read (a filesystem tool) with a database ID, get "file not found", and re-analyze from scratch, burning tokens. - lcm_read calls the existing getLargeFileContent internal function and returns the actual stored payload (inline DB text or disk-backed file). - Sub-agent gated (same pattern as lcm_expand) to keep main context lean. - Added lcm_read to explore agent permission allowlist. - Updated reference messages in large-tool-output, lcm-describe, and lcm-expand to direct the model toward lcm_read via Task sub-agents. - Added TUI render component and hidden-tool registration. Co-Authored-By: Claude Opus 4.6 --- packages/voltcode/src/agent/agent.ts | 1 + .../src/cli/cmd/tui/routes/session/index.tsx | 16 +- .../voltcode/src/session/large-tool-output.ts | 2 +- packages/voltcode/src/tool/lcm-describe.txt | 2 + packages/voltcode/src/tool/lcm-expand.ts | 2 +- packages/voltcode/src/tool/lcm-expand.txt | 2 +- packages/voltcode/src/tool/lcm-read.ts | 140 ++++++++++++++++++ packages/voltcode/src/tool/lcm-read.txt | 10 ++ packages/voltcode/src/tool/registry.ts | 2 + 9 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 packages/voltcode/src/tool/lcm-read.ts create mode 100644 packages/voltcode/src/tool/lcm-read.txt diff --git a/packages/voltcode/src/agent/agent.ts b/packages/voltcode/src/agent/agent.ts index cbd47d4c8..a7b81837d 100644 --- a/packages/voltcode/src/agent/agent.ts +++ b/packages/voltcode/src/agent/agent.ts @@ -156,6 +156,7 @@ export namespace Agent { lcm_describe: "allow", lcm_expand: "allow", lcm_grep: "allow", + lcm_read: "allow", external_directory: { [Truncate.DIR]: "allow", [Truncate.GLOB]: "allow", diff --git a/packages/voltcode/src/cli/cmd/tui/routes/session/index.tsx b/packages/voltcode/src/cli/cmd/tui/routes/session/index.tsx index e2b65bee1..d89234c26 100644 --- a/packages/voltcode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/voltcode/src/cli/cmd/tui/routes/session/index.tsx @@ -48,6 +48,7 @@ import type { TasksTool } from "@/tool/tasks" import type { QuestionTool } from "@/tool/question" import type { LcmExpandTool } from "@/tool/lcm-expand" import type { LcmGrepTool } from "@/tool/lcm-grep" +import type { LcmReadTool } from "@/tool/lcm-read" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" @@ -1558,7 +1559,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las const ctx = use() // Internal LCM tools that are always hidden (not in dev mode) - const LCM_INTERNAL_TOOLS = ["lcm_expand", "lcm_grep"] + const LCM_INTERNAL_TOOLS = ["lcm_expand", "lcm_grep", "lcm_read"] // Check if there are hidden tools with no visible content const hasHiddenToolsOnly = createMemo(() => { @@ -1836,7 +1837,7 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess const sync = useSync() // Internal LCM tools that should only be visible in dev mode - const LCM_INTERNAL_TOOLS = ["lcm_expand", "lcm_grep"] + const LCM_INTERNAL_TOOLS = ["lcm_expand", "lcm_grep", "lcm_read"] // Hide tool if showDetails is false and tool completed successfully // Hide internal LCM tools (lcm_expand, lcm_grep) when not in dev mode @@ -1889,6 +1890,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess + + + @@ -2238,6 +2242,14 @@ function LcmGrep(props: ToolProps) { ) } +function LcmRead(props: ToolProps) { + return ( + + LCM read {props.input.file_id} + + ) +} + function Grep(props: ToolProps) { return ( diff --git a/packages/voltcode/src/session/large-tool-output.ts b/packages/voltcode/src/session/large-tool-output.ts index 3078d6922..8ec1b8a05 100644 --- a/packages/voltcode/src/session/large-tool-output.ts +++ b/packages/voltcode/src/session/large-tool-output.ts @@ -111,7 +111,7 @@ export async function handleLargeToolOutput(input: { preview, hasMore ? `\n...[${tokenCount - Token.estimate(preview)} more tokens]` : "", ``, - `The full output is stored in LCM. Use lcm_describe with file_id "${fileId}" to see metadata. Do NOT attempt to read this content with the Read tool — it is stored in the LCM database, not as a file on disk.`, + `The full output is stored in LCM. To retrieve it, spawn a Task sub-agent with lcm_read (file_id "${fileId}"). Use lcm_describe for metadata only. Do NOT attempt to read this content with the Read tool — it is stored in the LCM database, not as a file on disk.`, ].join("\n") return { diff --git a/packages/voltcode/src/tool/lcm-describe.txt b/packages/voltcode/src/tool/lcm-describe.txt index d69ccbd96..cd93d3421 100644 --- a/packages/voltcode/src/tool/lcm-describe.txt +++ b/packages/voltcode/src/tool/lcm-describe.txt @@ -6,3 +6,5 @@ This tool retrieves information about items stored in LCM (Lossless Context Mana - For summary IDs (sum_xxx): Returns the summary content, kind (leaf/condensed), token count, and parent summaries if condensed. Use this tool when you have an LCM ID and need to understand what it refers to before deciding how to work with it. + +This tool returns metadata only — NOT the full stored content. To retrieve the actual content, use lcm_read (via a Task sub-agent). diff --git a/packages/voltcode/src/tool/lcm-expand.ts b/packages/voltcode/src/tool/lcm-expand.ts index 3787c9bf1..f71ba07d4 100644 --- a/packages/voltcode/src/tool/lcm-expand.ts +++ b/packages/voltcode/src/tool/lcm-expand.ts @@ -59,7 +59,7 @@ The sub-agent will be able to call lcm_expand to see the full content.`, summaryId: params.summary_id, messageCount: 0, }, - output: `ERROR: lcm_expand cannot be called on an LCM file ID. "${params.summary_id}" is a stored file, not a conversation summary.\n\nTo learn more about this file, you can:\n- Call lcm_describe with ID "${params.summary_id}" to retrieve the results of the file exploration agent\n- Use your bash and filesystem tools to read parts of the file, search and filter its contents, or write a program that manipulates the file`, + output: `ERROR: lcm_expand cannot be called on an LCM file ID. "${params.summary_id}" is a stored file, not a conversation summary.\n\nTo work with this file:\n- Call lcm_describe with ID "${params.summary_id}" for metadata and exploration summary\n- Spawn a Task sub-agent with lcm_read to retrieve the full stored content`, } } throw new LcmDb.NotFoundError({ diff --git a/packages/voltcode/src/tool/lcm-expand.txt b/packages/voltcode/src/tool/lcm-expand.txt index f763fcdde..7b9b8c3e2 100644 --- a/packages/voltcode/src/tool/lcm-expand.txt +++ b/packages/voltcode/src/tool/lcm-expand.txt @@ -1,7 +1,7 @@ - Expand a summary to see its full underlying content - Retrieves the original messages or summaries that were compressed - Adds the expanded content to your current context -- IMPORTANT: Only works with summary IDs (sum_xxx). Does NOT work with file IDs (file_xxx). For file content, use the Read tool with the file path instead. +- IMPORTANT: Only works with summary IDs (sum_xxx). Does NOT work with file IDs (file_xxx). For file content, use lcm_read (via a Task sub-agent). - IMPORTANT: Only callable by sub-agents spawned via the Task tool - Main agent gets error: "Only sub-agents can expand summaries" - Spawn a Task sub-agent if you need to expand summaries from the main context diff --git a/packages/voltcode/src/tool/lcm-read.ts b/packages/voltcode/src/tool/lcm-read.ts new file mode 100644 index 000000000..176f2386a --- /dev/null +++ b/packages/voltcode/src/tool/lcm-read.ts @@ -0,0 +1,140 @@ +import z from "zod" +import { Tool } from "./tool" +import { LcmDb } from "../session/lcm/db" +import { Session } from "../session" +import { SessionPrompt } from "../session/prompt" +import { Log } from "../util/log" +import DESCRIPTION from "./lcm-read.txt" + +const log = Log.create({ service: "tool.lcm_read" }) + +/** Default byte cap — ~25k tokens at ~4 chars/token. */ +const DEFAULT_MAX_BYTES = 100_000 + +const parameters = z.object({ + file_id: z.string().describe("The LCM file ID to read (file_xxx format)"), + max_bytes: z + .number() + .min(1) + .optional() + .describe("Optional byte limit for very large payloads (default: 100000)"), +}) + +interface LcmReadMetadata { + fileId: string + found: boolean + truncated: boolean + totalSize: number + storageKind?: string +} + +export const LcmReadTool = Tool.define("lcm_read", { + description: DESCRIPTION, + parameters, + async execute(params, ctx) { + // Sub-agent gate — same pattern as lcm_expand. + const session = await Session.get(ctx.sessionID) + if (!session.parentID) { + return { + title: `Read LCM file: ${params.file_id}`, + metadata: { + fileId: params.file_id, + found: false, + truncated: false, + totalSize: 0, + }, + output: `ERROR: Only sub-agents can read full LCM file content. + +The lcm_read tool can only be called by sub-agents spawned via the Task tool. +This restriction protects the main context from uncontrolled expansion. + +To retrieve the content of "${params.file_id}", spawn a Task sub-agent: + Task(prompt="Use lcm_read on ${params.file_id} to find ") + +The sub-agent will be able to call lcm_read and return a focused answer.`, + } + } + + const fileId = params.file_id.trim() + + if (!fileId.startsWith("file_")) { + return { + title: `LCM read: ${fileId}`, + metadata: { + fileId, + found: false, + truncated: false, + totalSize: 0, + }, + output: `Invalid ID format: "${fileId}". lcm_read accepts file IDs (file_xxx). For summary IDs (sum_xxx), use lcm_expand instead.`, + } + } + + // Scope lookup to this conversation's ancestry chain. + const conversationId = await SessionPrompt.getLcmConversationId(ctx.sessionID) + + const maxBytes = params.max_bytes ?? DEFAULT_MAX_BYTES + + log.info("reading LCM file content", { fileId, maxBytes, sessionId: ctx.sessionID }) + + const result = await LcmDb.getLargeFileContent(fileId, maxBytes, conversationId ?? undefined) + + if (!result) { + // Distinguish "ID not found" from "file on disk missing". + const exists = await LcmDb.largeFileExists(fileId, conversationId ?? undefined) + if (exists) { + return { + title: `LCM read: ${fileId}`, + metadata: { + fileId, + found: true, + truncated: false, + totalSize: 0, + }, + output: `File record exists but content could not be read — the backing file may have been moved or deleted.\n\nUse lcm_describe with "${fileId}" for metadata and exploration summary.`, + } + } + + return { + title: `LCM read: ${fileId}`, + metadata: { + fileId, + found: false, + truncated: false, + totalSize: 0, + }, + output: `File not found: ${fileId}\n\nThis file ID does not exist in the current conversation or its ancestors.`, + } + } + + log.info("read LCM file content", { + fileId, + totalSize: result.totalSize, + truncated: result.truncated, + }) + + const lines: string[] = [] + lines.push(`## LCM File Content: ${fileId}`) + lines.push("") + + if (result.truncated) { + lines.push( + `**Note:** Content truncated to ${maxBytes.toLocaleString()} bytes (full size: ${result.totalSize.toLocaleString()} bytes). Call again with a larger max_bytes to see more.`, + ) + lines.push("") + } + + lines.push(result.content) + + return { + title: `Read LCM: ${fileId} (${result.totalSize.toLocaleString()} bytes)`, + metadata: { + fileId, + found: true, + truncated: result.truncated, + totalSize: result.totalSize, + }, + output: lines.join("\n"), + } + }, +}) diff --git a/packages/voltcode/src/tool/lcm-read.txt b/packages/voltcode/src/tool/lcm-read.txt new file mode 100644 index 000000000..9d3f47af2 --- /dev/null +++ b/packages/voltcode/src/tool/lcm-read.txt @@ -0,0 +1,10 @@ +Retrieve the full stored content of an LCM file by its file ID. + +Use this tool when you need the actual content of something stored in LCM — large tool outputs, pasted user text, or any payload that was too large for inline context. + +- Accepts file IDs (file_xxx format). Does NOT work with summary IDs (sum_xxx) — use lcm_expand for those. +- Returns the stored content directly (text payloads from DB, or file content from disk). +- Supports optional byte limit to avoid overwhelming context with very large payloads. +- IMPORTANT: Only callable by sub-agents spawned via the Task tool. Main agent gets an error with instructions to spawn a sub-agent. +- To retrieve stored content from the main context, spawn a Task sub-agent: + Task(prompt="Use lcm_read on file_xxx to find ") diff --git a/packages/voltcode/src/tool/registry.ts b/packages/voltcode/src/tool/registry.ts index 7b558d586..0572de2db 100644 --- a/packages/voltcode/src/tool/registry.ts +++ b/packages/voltcode/src/tool/registry.ts @@ -33,6 +33,7 @@ import { ApplyPatchTool } from "./apply_patch" import { LcmGrepTool } from "./lcm-grep" import { LcmExpandTool } from "./lcm-expand" import { LcmDescribeTool } from "./lcm-describe" +import { LcmReadTool } from "./lcm-read" import { AgenticMapTool } from "./agentic-map" import { LlmMapTool } from "./llm-map" @@ -142,6 +143,7 @@ export namespace ToolRegistry { LcmGrepTool, LcmExpandTool, LcmDescribeTool, + LcmReadTool, AgenticMapTool, LlmMapTool, ...custom, From d928736c0f6e24a97818b252728522399a9c0d06 Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 14:49:23 -0800 Subject: [PATCH 04/18] fix: prevent processor from re-storing lcm_read output back into LCM lcm_read retrieved content correctly from the LCM database, but the processor's large-output handler immediately re-ingested it (>10k tokens) and replaced it with a 2k reference stub. The model never saw the content no matter how many times it called lcm_read. - lcm_read now sets metadata.lcm.storedInLcm on successful retrieval - processor skips handleLargeToolOutput when content already came from LCM - Updated all guidance messages to direct models to explore sub-agents (which cannot spawn tasks) instead of general sub-agents (which recurse) Verified end-to-end: tasks tool output stored as inline_text, explore sub-agent called lcm_read, got full 50,934 bytes back, no re-storage. Co-Authored-By: Claude Opus 4.6 --- packages/voltcode/src/session/large-tool-output.ts | 2 +- packages/voltcode/src/session/processor.ts | 3 ++- packages/voltcode/src/tool/lcm-describe.txt | 3 ++- packages/voltcode/src/tool/lcm-expand.ts | 2 +- packages/voltcode/src/tool/lcm-expand.txt | 2 +- packages/voltcode/src/tool/lcm-read.ts | 8 +++++--- packages/voltcode/src/tool/lcm-read.txt | 4 ++-- 7 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/voltcode/src/session/large-tool-output.ts b/packages/voltcode/src/session/large-tool-output.ts index 8ec1b8a05..19c19c565 100644 --- a/packages/voltcode/src/session/large-tool-output.ts +++ b/packages/voltcode/src/session/large-tool-output.ts @@ -111,7 +111,7 @@ export async function handleLargeToolOutput(input: { preview, hasMore ? `\n...[${tokenCount - Token.estimate(preview)} more tokens]` : "", ``, - `The full output is stored in LCM. To retrieve it, spawn a Task sub-agent with lcm_read (file_id "${fileId}"). Use lcm_describe for metadata only. Do NOT attempt to read this content with the Read tool — it is stored in the LCM database, not as a file on disk.`, + `The full output is stored in LCM. To retrieve it, spawn an explore sub-agent: Task(subagent_type="explore", prompt="Use lcm_read on ${fileId} to find "). Use lcm_describe for metadata only. Do NOT attempt to read this content with the Read tool — it is stored in the LCM database, not as a file on disk.`, ].join("\n") return { diff --git a/packages/voltcode/src/session/processor.ts b/packages/voltcode/src/session/processor.ts index bb1b19cbb..42155f0ec 100644 --- a/packages/voltcode/src/session/processor.ts +++ b/packages/voltcode/src/session/processor.ts @@ -536,7 +536,8 @@ export namespace SessionProcessor { if (typeof toolResult.output === "string") { const outputTokens = Token.estimate(toolResult.output) - if (outputTokens > LARGE_TOOL_OUTPUT_THRESHOLD) { + // Skip LCM re-storage if the output is already from LCM (e.g. lcm_read) + if (outputTokens > LARGE_TOOL_OUTPUT_THRESHOLD && !lcmMetadata?.storedInLcm) { // Large output: store in LCM and replace with reference const lcmResult = await handleLargeToolOutput({ sessionID: input.sessionID, diff --git a/packages/voltcode/src/tool/lcm-describe.txt b/packages/voltcode/src/tool/lcm-describe.txt index cd93d3421..b16b85369 100644 --- a/packages/voltcode/src/tool/lcm-describe.txt +++ b/packages/voltcode/src/tool/lcm-describe.txt @@ -7,4 +7,5 @@ This tool retrieves information about items stored in LCM (Lossless Context Mana Use this tool when you have an LCM ID and need to understand what it refers to before deciding how to work with it. -This tool returns metadata only — NOT the full stored content. To retrieve the actual content, use lcm_read (via a Task sub-agent). +This tool returns metadata only — NOT the full stored content. To retrieve the actual content, use lcm_read via an explore sub-agent: + Task(subagent_type="explore", prompt="Use lcm_read on to find ") diff --git a/packages/voltcode/src/tool/lcm-expand.ts b/packages/voltcode/src/tool/lcm-expand.ts index f71ba07d4..cfe095662 100644 --- a/packages/voltcode/src/tool/lcm-expand.ts +++ b/packages/voltcode/src/tool/lcm-expand.ts @@ -59,7 +59,7 @@ The sub-agent will be able to call lcm_expand to see the full content.`, summaryId: params.summary_id, messageCount: 0, }, - output: `ERROR: lcm_expand cannot be called on an LCM file ID. "${params.summary_id}" is a stored file, not a conversation summary.\n\nTo work with this file:\n- Call lcm_describe with ID "${params.summary_id}" for metadata and exploration summary\n- Spawn a Task sub-agent with lcm_read to retrieve the full stored content`, + output: `ERROR: lcm_expand cannot be called on an LCM file ID. "${params.summary_id}" is a stored file, not a conversation summary.\n\nTo work with this file:\n- Call lcm_describe with ID "${params.summary_id}" for metadata and exploration summary\n- Spawn an explore sub-agent to retrieve content: Task(subagent_type="explore", prompt="Use lcm_read on ${params.summary_id} to find ")`, } } throw new LcmDb.NotFoundError({ diff --git a/packages/voltcode/src/tool/lcm-expand.txt b/packages/voltcode/src/tool/lcm-expand.txt index 7b9b8c3e2..086c5ce8e 100644 --- a/packages/voltcode/src/tool/lcm-expand.txt +++ b/packages/voltcode/src/tool/lcm-expand.txt @@ -1,7 +1,7 @@ - Expand a summary to see its full underlying content - Retrieves the original messages or summaries that were compressed - Adds the expanded content to your current context -- IMPORTANT: Only works with summary IDs (sum_xxx). Does NOT work with file IDs (file_xxx). For file content, use lcm_read (via a Task sub-agent). +- IMPORTANT: Only works with summary IDs (sum_xxx). Does NOT work with file IDs (file_xxx). For file content, use lcm_read via an explore sub-agent. - IMPORTANT: Only callable by sub-agents spawned via the Task tool - Main agent gets error: "Only sub-agents can expand summaries" - Spawn a Task sub-agent if you need to expand summaries from the main context diff --git a/packages/voltcode/src/tool/lcm-read.ts b/packages/voltcode/src/tool/lcm-read.ts index 176f2386a..1914714c4 100644 --- a/packages/voltcode/src/tool/lcm-read.ts +++ b/packages/voltcode/src/tool/lcm-read.ts @@ -48,10 +48,10 @@ export const LcmReadTool = Tool.define("lcm_ The lcm_read tool can only be called by sub-agents spawned via the Task tool. This restriction protects the main context from uncontrolled expansion. -To retrieve the content of "${params.file_id}", spawn a Task sub-agent: - Task(prompt="Use lcm_read on ${params.file_id} to find ") +To retrieve the content of "${params.file_id}", spawn an explore sub-agent: + Task(subagent_type="explore", prompt="Use lcm_read on ${params.file_id} to find ") -The sub-agent will be able to call lcm_read and return a focused answer.`, +The explore sub-agent will call lcm_read and return a focused answer.`, } } @@ -133,6 +133,8 @@ The sub-agent will be able to call lcm_read and return a focused answer.`, found: true, truncated: result.truncated, totalSize: result.totalSize, + // Signal to the processor that this is already LCM content — do not re-store. + lcm: { storedInLcm: true, fileId }, }, output: lines.join("\n"), } diff --git a/packages/voltcode/src/tool/lcm-read.txt b/packages/voltcode/src/tool/lcm-read.txt index 9d3f47af2..1d1179e85 100644 --- a/packages/voltcode/src/tool/lcm-read.txt +++ b/packages/voltcode/src/tool/lcm-read.txt @@ -6,5 +6,5 @@ Use this tool when you need the actual content of something stored in LCM — la - Returns the stored content directly (text payloads from DB, or file content from disk). - Supports optional byte limit to avoid overwhelming context with very large payloads. - IMPORTANT: Only callable by sub-agents spawned via the Task tool. Main agent gets an error with instructions to spawn a sub-agent. -- To retrieve stored content from the main context, spawn a Task sub-agent: - Task(prompt="Use lcm_read on file_xxx to find ") +- To retrieve stored content from the main context, spawn an explore sub-agent: + Task(subagent_type="explore", prompt="Use lcm_read on file_xxx to find ") From 6b99ceb1655eb4885b66250fa830fc0f233edbe6 Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 15:24:36 -0800 Subject: [PATCH 05/18] chore: create pebbles for PR review fixes - Epic volt-156: PR review fixes for lcm-inline-content-path-resolution - 9 subtasks covering DRY violations, type safety, migration atomicity, backward-compat removal, multi-tenant schema gap, and inline_binary handling - volt-640 blocked on volt-d7e (dependency) - volt-44e (upgrade revert) assigned to coordinator Co-Authored-By: Claude Opus 4.6 --- .pebbles/.gitignore | 1 + .pebbles/config.json | 3 +++ .pebbles/events.jsonl | 38 ++++++++++++++++++++++++++++++++++++++ .pebbles/pebbles.db | Bin 0 -> 102400 bytes 4 files changed, 42 insertions(+) create mode 100644 .pebbles/.gitignore create mode 100644 .pebbles/config.json create mode 100644 .pebbles/events.jsonl create mode 100644 .pebbles/pebbles.db diff --git a/.pebbles/.gitignore b/.pebbles/.gitignore new file mode 100644 index 000000000..0a168c659 --- /dev/null +++ b/.pebbles/.gitignore @@ -0,0 +1 @@ +pebbles.db diff --git a/.pebbles/config.json b/.pebbles/config.json new file mode 100644 index 000000000..5174be7f4 --- /dev/null +++ b/.pebbles/config.json @@ -0,0 +1,3 @@ +{ + "prefix": "volt" +} \ No newline at end of file diff --git a/.pebbles/events.jsonl b/.pebbles/events.jsonl new file mode 100644 index 000000000..398c7a9d7 --- /dev/null +++ b/.pebbles/events.jsonl @@ -0,0 +1,38 @@ +{"type":"create","timestamp":"2026-02-25T23:16:16.169294Z","issue_id":"volt-156","payload":{"description":"Fixes identified during code review (self-review + independent Codex review) of the\nfix/lcm-inline-content-path-resolution branch before merge to dev.\n\nThe branch introduces lcm_read (a tool that lets models retrieve large content stored\nin LCM by file ID) and fixes the storage model for inline payloads. The core feature\nworks and was verified E2E, but review surfaced 9 discrete issues ranging from type\nsafety holes to a high-severity multi-tenant migration gap.\n\nThese subtasks are ordered by dependency. The upgrade refactor revert (C6) is\nindependent and will be handled by the coordinator. All others are delegated to\nCodex workers.","priority":"2","title":"PR review fixes for lcm-inline-content-path-resolution","type":"epic"}} +{"type":"create","timestamp":"2026-02-25T23:16:29.282808Z","issue_id":"volt-4cf","payload":{"description":"## Problem\n\nThe array [\"lcm_expand\", \"lcm_grep\", \"lcm_read\"] is defined as a const inside TWO\nseparate component functions in packages/voltcode/src/cli/cmd/tui/routes/session/index.tsx:\n\n1. Inside AssistantMessage (line ~1559)\n2. Inside ToolPart (line ~1837)\n\nIf a new LCM tool is added, both must be updated or the UI silently breaks for one\nrendering path. This is a DRY violation.\n\n## Required change\n\n1. Extract `LCM_INTERNAL_TOOLS` to a single module-level const, defined once near the\n top of session/index.tsx (or in a shared constants file if one exists nearby).\n2. Replace both inline definitions with references to the shared const.\n3. At each usage site, add a brief comment: // See LCM_INTERNAL_TOOLS definition above\n (or the import path if extracted to a separate file).\n\n## Scope boundaries\n\n- Do NOT refactor anything else in session/index.tsx.\n- Do NOT change the contents of the array — only where it's defined.\n- Do NOT move the component functions or change their signatures.\n\n## Acceptance criteria\n\n- grep -c \"LCM_INTERNAL_TOOLS\" on session/index.tsx shows exactly 1 definition\n and 2+ references (not 2 definitions).\n- The TUI still renders correctly (typecheck passes, no runtime change).","priority":"2","title":"DRY out LCM_INTERNAL_TOOLS constant in TUI","type":"task"}} +{"type":"create","timestamp":"2026-02-25T23:16:37.914032Z","issue_id":"volt-59c","payload":{"description":"## Problem\n\npackages/voltcode/src/tool/lcm-read.ts defines:\n\n interface LcmReadMetadata {\n fileId: string\n found: boolean\n truncated: boolean\n totalSize: number\n storageKind?: string // \u003c-- THIS\n }\n\nThe successful return path (lines 129-140) never populates storageKind. It is always\nundefined at runtime. This is dead surface area that confuses future readers into\nthinking the field carries meaningful data.\n\nThe storageKind is not surfaced to the model (metadata goes to the processor, not\nthe model's context), so there is no value in returning it.\n\n## Required change\n\nRemove the `storageKind?: string` line from the LcmReadMetadata interface.\n\n## Scope boundaries\n\n- Do NOT add storageKind population logic. We're removing the field, not fixing it.\n- Do NOT change any other interface or type in the file.\n\n## Acceptance criteria\n\n- LcmReadMetadata has exactly 4 fields: fileId, found, truncated, totalSize.\n- Typecheck passes.","priority":"3","title":"Remove dead storageKind field from LcmReadMetadata","type":"task"}} +{"type":"create","timestamp":"2026-02-25T23:16:55.936937Z","issue_id":"volt-52b","payload":{"description":"## Problem\n\nlcm_read signals the processor to skip re-storage by setting metadata.lcm = { storedInLcm: true, fileId }.\nThe processor reads this at processor.ts:534 via toolResult.metadata?.lcm?.storedInLcm.\n\nBut:\n- LcmReadMetadata (lcm-read.ts:23-29) does NOT include an lcm field.\n- The processor accesses it through TypeScript's structural typing / any escape.\n- If Tool.define's metadata generic is ever tightened, this breaks silently at runtime\n with no compile-time error.\n\n## Context: how Tool.define metadata works\n\nLook at packages/voltcode/src/tool/tool.ts to understand the Tool.define\u003cP, M\u003e generic.\nThe second type parameter M is the metadata type. The processor receives toolResult.metadata\ntyped as whatever M is. Right now it's loose enough that extra fields sneak through, but\nthat's an accident of the current type system, not a contract.\n\n## Required change\n\n1. Define a shared type in a sensible location (e.g. packages/voltcode/src/session/lcm/types.ts\n or alongside the existing LCM types — use your judgment):\n\n export interface LcmToolMetadata {\n storedInLcm: boolean\n fileId: string\n }\n\n2. Update LcmReadMetadata in lcm-read.ts to include:\n\n lcm?: LcmToolMetadata\n\n3. Update processor.ts to import and use LcmToolMetadata for the type of lcmMetadata,\n rather than relying on any/structural typing. The access pattern\n toolResult.metadata?.lcm?.storedInLcm should be type-safe after this change.\n\n4. If other LCM tools (lcm_expand, lcm_describe, lcm_grep) also set metadata.lcm,\n update them too. If they don't, leave them alone.\n\n## Scope boundaries\n\n- Do NOT change the runtime behavior. The same values flow through the same paths.\n This is purely a type-safety improvement.\n- Do NOT refactor the processor's large-output handling logic.\n- Do NOT add new metadata fields beyond what already exists.\n\n## Acceptance criteria\n\n- toolResult.metadata?.lcm?.storedInLcm access in processor.ts is fully typed\n (no any, no structural escape).\n- LcmReadMetadata includes lcm?: LcmToolMetadata.\n- Typecheck passes with no new suppressions or casts.\n- The shared type is importable from a single canonical location.","priority":"1","title":"Type the metadata.lcm escape hatch between lcm_read and processor","type":"task"}} +{"type":"create","timestamp":"2026-02-25T23:17:55.936468Z","issue_id":"volt-d7e","payload":{"description":"## Problem\n\npackages/voltcode/src/session/lcm/db.ts lines 2019-2031 contain a backward-compatibility\nfallback in getLargeFileContent:\n\n // Backward-compatibility fallback for rows that predate storage_kind enforcement.\n if (row.original_path) {\n const file = Bun.file(row.original_path)\n const exists = await file.exists()\n if (!exists) {\n log.warn(\"legacy large file path not found on disk\", { fileId, path: row.original_path })\n return null\n }\n const content = await file.text()\n return { content, truncated: false, totalSize: content.length }\n }\n\nThis code:\n1. Ignores the maxBytes parameter entirely (reads full file into memory).\n2. Always returns truncated: false regardless of actual size.\n3. Is unreachable after the migration runs (the migration backfills storage_kind\n on ALL existing rows, so the code above this block always matches).\n\nWe want a clean cutover. No backward-compat shims.\n\n## Required change\n\nDelete the entire backward-compatibility fallback block (the if (row.original_path)\nblock that comes AFTER the storage_kind === \"path\" branch). The function should fall\nthrough to `return null` after the path and inline_text branches.\n\nAlso delete the comment \"Backward-compatibility fallback for rows that predate\nstorage_kind enforcement.\"\n\n## Scope boundaries\n\n- Do NOT modify the storage_kind === \"path\" branch above it (that one is correct\n and respects maxBytes).\n- Do NOT modify the inline_text branch.\n- Do NOT add any new fallback logic. If storage_kind is somehow unrecognized,\n returning null is correct.\n\n## Acceptance criteria\n\n- The backward-compat block is gone.\n- getLargeFileContent has exactly three code paths: inline_text → return content,\n path → read from disk with maxBytes, everything else → return null.\n- Typecheck passes.","priority":"1","title":"Delete backward-compat fallback in getLargeFileContent","type":"task"}} +{"type":"create","timestamp":"2026-02-25T23:18:18.213979Z","issue_id":"volt-66c","payload":{"description":"## Problem\n\npackages/voltcode/src/session/lcm/db.ts lines ~461-478 contain two separate DO blocks\nfor the large_files_storage_shape_check constraint:\n\nBlock 1 (DROP): Checks if constraint exists, drops it.\nBlock 2 (ADD): Adds constraint, catches duplicate_object.\n\nIf execution crashes between the DROP and ADD, the table is left without a constraint.\nAdditionally, every app startup unconditionally drops and recreates the constraint even\nwhen nothing changed — that's a gratuitous write on every boot.\n\n## Required change\n\nCombine both operations into a single DO block so the DROP and ADD are atomic within\none PL/pgSQL execution. Also add a guard: only drop-and-recreate if the constraint\ndefinition has actually changed.\n\nHere's the pattern:\n\n DO \\$\\$ \n DECLARE\n current_def text;\n desired_def text := '((storage_kind = ...))'; -- the full CHECK body\n BEGIN\n -- Get current constraint definition if it exists\n SELECT pg_get_constraintdef(oid) INTO current_def\n FROM pg_constraint\n WHERE conname = 'large_files_storage_shape_check'\n AND conrelid = 'large_files'::regclass;\n\n -- Only recreate if missing or changed\n IF current_def IS NULL OR current_def != desired_def THEN\n IF current_def IS NOT NULL THEN\n EXECUTE 'ALTER TABLE large_files DROP CONSTRAINT large_files_storage_shape_check';\n END IF;\n ALTER TABLE large_files\n ADD CONSTRAINT large_files_storage_shape_check CHECK (\n (storage_kind = 'path' AND original_path IS NOT NULL AND content IS NULL AND binary_content IS NULL) OR\n (storage_kind = 'inline_text' AND content IS NOT NULL AND binary_content IS NULL) OR\n (storage_kind = 'inline_binary' AND binary_content IS NOT NULL AND content IS NULL)\n );\n END IF;\n END \\$\\$;\n\nNOTE: The desired_def string must match what pg_get_constraintdef returns for the\nCHECK expression. You'll need to check the exact format PostgreSQL uses (it may\nnormalize parentheses, spacing, etc.). Test by running the migration, then querying\npg_get_constraintdef to see the canonical form, and use THAT as your comparison string.\n\nIf getting the exact string match is fiddly, an acceptable alternative is: just wrap\nthe DROP + ADD in a single DO block (so they're atomic) and skip the \"has it changed\"\noptimization. Atomicity is the must-have; skip-if-unchanged is nice-to-have.\n\n## Scope boundaries\n\n- Only touch the CHECK constraint migration block.\n- Do NOT modify the constraint definition itself.\n- Do NOT change other migrations in the same file.\n\n## Acceptance criteria\n\n- The DROP and ADD are in a single DO block (atomic execution).\n- Typecheck passes.\n- If you can verify: running the migration twice in a row does not error.","priority":"2","title":"Make CHECK constraint migration atomic and idempotent","type":"task"}} +{"type":"create","timestamp":"2026-02-25T23:18:31.864765Z","issue_id":"volt-44e","payload":{"description":"## Problem\n\nCommit 70a5534e0 (\"refactor: replace upstream upgrade sources with voltropy install\nscript\") is bundled into the fix/lcm-inline-content-path-resolution branch but has\nnothing to do with the LCM bug fix. It removes ~170 lines of package-manager upgrade\npaths (npm, brew, choco, scoop) and replaces them with a single voltropy.com endpoint.\n\nThis inflates the diff, complicates review, and mixes concerns. Additionally, Voltropy\nis handing the product off to Martian Engineering, so the upgrade path needs separate\nconsideration.\n\n## Required change\n\nRevert commit 70a5534e0 from this branch. The upgrade refactor can be re-applied\nas a separate PR after the LCM fix lands.\n\n## Owner\n\nCoordinator (not delegated to a worker).\n\n## Acceptance criteria\n\n- git log dev..HEAD no longer contains 70a5534e0.\n- installation/index.ts, cli/cmd/upgrade.ts, and cli/upgrade.ts match their state on dev.\n- All other commits on the branch are preserved.\n- Typecheck passes.","priority":"2","title":"Revert upgrade refactor from LCM PR branch","type":"task"}} +{"type":"create","timestamp":"2026-02-25T23:18:45.899017Z","issue_id":"volt-986","payload":{"description":"## Problem\n\npackages/voltcode/src/tool/lcm-read.ts lines 16-20 define:\n\n max_bytes: z\n .number()\n .min(1)\n .optional()\n .describe(\"Optional byte limit for very large payloads (default: 100000)\")\n\nThere is no .max() guard. A model could pass max_bytes: 999_999_999. The path-backed\nbranch in getLargeFileContent (db.ts) already has a 100MB safety cap:\n\n const safeMax = Math.min(maxBytes ?? 100 * 1024 * 1024, 100 * 1024 * 1024)\n\nBut the inline_text branch does not — it trusts maxBytes directly. Capping at the\nZod level makes the schema the single source of truth for the limit.\n\n## Required change\n\nAdd .max(100_000_000) to the max_bytes Zod chain:\n\n max_bytes: z\n .number()\n .min(1)\n .max(100_000_000)\n .optional()\n .describe(\"Optional byte limit for very large payloads (default: 100000)\")\n\n100_000_000 (100MB) matches the existing safety cap in the path-backed branch.\n\n## Scope boundaries\n\n- Only change the Zod schema definition.\n- Do NOT change the DEFAULT_MAX_BYTES constant (100_000) — that's the default,\n not the maximum.\n- Do NOT modify getLargeFileContent.\n\n## Acceptance criteria\n\n- max_bytes has both .min(1) and .max(100_000_000).\n- Typecheck passes.","priority":"2","title":"Cap max_bytes at 100MB in lcm_read Zod schema","type":"task"}} +{"type":"create","timestamp":"2026-02-25T23:19:02.850017Z","issue_id":"volt-727","payload":{"description":"## Problem (HIGH SEVERITY — identified by independent Codex review)\n\npackages/voltcode/src/session/lcm/user-context.ts manages per-user PostgreSQL schemas\nfor multi-tenant cloud deployments. When a user schema is created, it runs\nCREATE TABLE IF NOT EXISTS for all LCM tables.\n\nThe storage_kind column was added to the public schema via ALTER TABLE migrations in\ndb.ts (lines ~433-478), including:\n- ALTER TABLE large_files ADD COLUMN storage_kind text\n- Backfill from existing data\n- SET DEFAULT 'path'\n- SET NOT NULL\n- DROP NOT NULL on original_path\n- ADD CHECK constraint\n\nBut user-context.ts has NONE of these migrations. Any existing user schema created\nbefore this change will crash with \"column storage_kind does not exist\" when\nlcm_describe or lcm_read queries hit SELECT ... storage_kind ... FROM large_files.\n\n## Required change\n\nIn the ensureUserSchema function in user-context.ts, after the CREATE TABLE IF NOT EXISTS\nblock for large_files, add the same migration steps that db.ts has:\n\n1. ALTER TABLE large_files ADD COLUMN storage_kind text (wrapped in exception handler\n for duplicate_column)\n2. Backfill: UPDATE large_files SET storage_kind = CASE WHEN content IS NOT NULL\n THEN 'inline_text' WHEN binary_content IS NOT NULL THEN 'inline_binary'\n ELSE 'path' END WHERE storage_kind IS NULL\n3. ALTER COLUMN storage_kind SET DEFAULT 'path'\n4. ALTER COLUMN storage_kind SET NOT NULL\n5. ALTER COLUMN original_path DROP NOT NULL\n6. The CHECK constraint (using the same atomic pattern from volt-66c if that's\n done first, otherwise the existing DROP+ADD pattern)\n\nAlso update the CREATE TABLE IF NOT EXISTS statement for large_files in user-context.ts\nto include storage_kind in the column list for NEW schemas, matching the definition\nin db.ts.\n\n## How to find the code\n\nLook at ensureUserSchema in user-context.ts. It has a large SQL template string that\ncreates all LCM tables. Find the large_files CREATE TABLE and add migrations after it.\n\nThen look at db.ts for the exact migration SQL to replicate.\n\n## Scope boundaries\n\n- Only modify user-context.ts.\n- Mirror the migration logic from db.ts — do not invent new migration patterns.\n- Do NOT modify db.ts itself.\n\n## Acceptance criteria\n\n- user-context.ts CREATE TABLE for large_files includes storage_kind column.\n- ALTER TABLE migrations for storage_kind exist in ensureUserSchema.\n- The backfill logic matches db.ts exactly.\n- Typecheck passes.","priority":"0","title":"Add storage_kind migration to multi-tenant user schemas","type":"task"}} +{"type":"create","timestamp":"2026-02-25T23:22:26.72798Z","issue_id":"volt-640","payload":{"description":"## Problem (identified by independent Codex review)\n\ngetLargeFileContent in db.ts handles two storage_kind values explicitly:\n- inline_text: returns content from the DB column\n- path: reads from disk with maxBytes slicing\n\nBut inline_binary is never handled. The code falls through to the backward-compat\nfallback (which we're deleting in volt-d7e), and then to return null.\n\nWhen getLargeFileContent returns null for an inline_binary record, lcm_read.ts\nchecks largeFileExists (which returns true — the row exists), and then tells the\nmodel: \"File record exists but content could not be read — the backing file may\nhave been moved or deleted.\"\n\nThis is wrong. The file wasn't moved or deleted — it's a binary blob stored in the\nDB. The error message is misleading.\n\n## Required change\n\n### In db.ts getLargeFileContent:\n\nAfter the inline_text branch and before the path branch, add an inline_binary branch:\n\n if (row.storage_kind === \"inline_binary\") {\n // Binary content cannot be returned as text. Return null so the caller\n // can give an appropriate error message.\n return null\n }\n\nThis is a no-op for retrieval (we can't usefully return binary as text), but it\nmakes the control flow explicit.\n\n### In lcm-read.ts:\n\nBefore the generic \"backing file may have been moved\" error, add a check for\nbinary content. You'll need to either:\n\n(a) Have getLargeFileContent return a discriminated result that says WHY it's null\n (not found vs binary vs disk missing), OR\n\n(b) Query getLargeFile (which returns the full row including storage_kind) and\n check storage_kind before giving the error message.\n\nOption (b) is simpler. The code already calls largeFileExists when result is null.\nInstead, call getLargeFile and check storage_kind:\n\n if (!result) {\n const file = await LcmDb.getLargeFile(fileId, conversationId ?? undefined)\n if (file) {\n if (file.storage_kind === \"inline_binary\") {\n return {\n title: `LCM read: ${fileId}`,\n metadata: { fileId, found: true, truncated: false, totalSize: 0 },\n output: `File \"${fileId}\" contains binary content (${file.mime_type}) which cannot be displayed as text.\\n\\nUse lcm_describe with \"${fileId}\" for metadata about this file.`,\n }\n }\n // Path-backed file whose disk content is missing\n return {\n title: `LCM read: ${fileId}`,\n metadata: { fileId, found: true, truncated: false, totalSize: 0 },\n output: `File record exists but content could not be read — the backing file may have been moved or deleted.\\n\\nUse lcm_describe with \"${fileId}\" for metadata and exploration summary.`,\n }\n }\n // Truly not found\n return { ... not found response ... }\n }\n\n## Dependencies\n\nThis task depends on volt-d7e (delete backward-compat fallback), since that task\nremoves the code path that inline_binary currently falls through to.\n\n## Scope boundaries\n\n- Do NOT attempt to decode or display binary content.\n- Do NOT change the inline_text or path branches in getLargeFileContent.\n- Do NOT change lcm_describe behavior.\n\n## Acceptance criteria\n\n- An inline_binary record does NOT produce \"backing file moved or deleted\" error.\n- An inline_binary record produces a clear message saying it's binary content.\n- A path-backed record with missing disk file still produces the \"moved or deleted\" message.\n- Typecheck passes.","priority":"2","title":"Handle inline_binary explicitly in getLargeFileContent and lcm_read","type":"task"}} +{"type":"rename","timestamp":"2026-02-25T23:22:35.834246Z","issue_id":"volt-4cf","payload":{"new_id":"volt-156.1"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:35.834246Z","issue_id":"volt-156.1","payload":{"dep_type":"parent-child","depends_on":"volt-156"}} +{"type":"rename","timestamp":"2026-02-25T23:22:35.85787Z","issue_id":"volt-59c","payload":{"new_id":"volt-156.2"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:35.85787Z","issue_id":"volt-156.2","payload":{"dep_type":"parent-child","depends_on":"volt-156"}} +{"type":"rename","timestamp":"2026-02-25T23:22:35.882562Z","issue_id":"volt-52b","payload":{"new_id":"volt-156.3"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:35.882562Z","issue_id":"volt-156.3","payload":{"dep_type":"parent-child","depends_on":"volt-156"}} +{"type":"rename","timestamp":"2026-02-25T23:22:35.90705Z","issue_id":"volt-d7e","payload":{"new_id":"volt-156.4"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:35.90705Z","issue_id":"volt-156.4","payload":{"dep_type":"parent-child","depends_on":"volt-156"}} +{"type":"rename","timestamp":"2026-02-25T23:22:35.934744Z","issue_id":"volt-66c","payload":{"new_id":"volt-156.5"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:35.934744Z","issue_id":"volt-156.5","payload":{"dep_type":"parent-child","depends_on":"volt-156"}} +{"type":"rename","timestamp":"2026-02-25T23:22:35.961712Z","issue_id":"volt-44e","payload":{"new_id":"volt-156.6"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:35.961712Z","issue_id":"volt-156.6","payload":{"dep_type":"parent-child","depends_on":"volt-156"}} +{"type":"rename","timestamp":"2026-02-25T23:22:35.989286Z","issue_id":"volt-986","payload":{"new_id":"volt-156.7"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:35.989286Z","issue_id":"volt-156.7","payload":{"dep_type":"parent-child","depends_on":"volt-156"}} +{"type":"rename","timestamp":"2026-02-25T23:22:36.015665Z","issue_id":"volt-727","payload":{"new_id":"volt-156.8"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:36.015665Z","issue_id":"volt-156.8","payload":{"dep_type":"parent-child","depends_on":"volt-156"}} +{"type":"rename","timestamp":"2026-02-25T23:22:36.0467Z","issue_id":"volt-640","payload":{"new_id":"volt-156.9"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:36.0467Z","issue_id":"volt-156.9","payload":{"dep_type":"parent-child","depends_on":"volt-156"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:42.517318Z","issue_id":"volt-156.9","payload":{"dep_type":"blocks","depends_on":"volt-156.4"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:42.546838Z","issue_id":"volt-156","payload":{"dep_type":"blocks","depends_on":"volt-156.1"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:42.577167Z","issue_id":"volt-156","payload":{"dep_type":"blocks","depends_on":"volt-156.2"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:42.607045Z","issue_id":"volt-156","payload":{"dep_type":"blocks","depends_on":"volt-156.3"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:42.636317Z","issue_id":"volt-156","payload":{"dep_type":"blocks","depends_on":"volt-156.4"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:42.668198Z","issue_id":"volt-156","payload":{"dep_type":"blocks","depends_on":"volt-156.5"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:42.697979Z","issue_id":"volt-156","payload":{"dep_type":"blocks","depends_on":"volt-156.6"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:42.728294Z","issue_id":"volt-156","payload":{"dep_type":"blocks","depends_on":"volt-156.7"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:42.75862Z","issue_id":"volt-156","payload":{"dep_type":"blocks","depends_on":"volt-156.8"}} +{"type":"dep_add","timestamp":"2026-02-25T23:22:42.792294Z","issue_id":"volt-156","payload":{"dep_type":"blocks","depends_on":"volt-156.9"}} diff --git a/.pebbles/pebbles.db b/.pebbles/pebbles.db new file mode 100644 index 0000000000000000000000000000000000000000..970ee35fa8207bd5c8b30983b9b2965f6df29742 GIT binary patch literal 102400 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 DXz#&< literal 0 HcmV?d00001 From b9e12ed5fe71cace5a33e20113986ed524b87d54 Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 15:32:01 -0800 Subject: [PATCH 06/18] Revert "refactor: replace upstream upgrade sources with voltropy install script" This reverts commit 70a5534e06f29937cb3b981af0ce420a565a2d91. --- packages/voltcode/src/cli/cmd/upgrade.ts | 44 ++++- packages/voltcode/src/cli/upgrade.ts | 6 +- packages/voltcode/src/installation/index.ts | 179 ++++++++++++++++++-- 3 files changed, 202 insertions(+), 27 deletions(-) diff --git a/packages/voltcode/src/cli/cmd/upgrade.ts b/packages/voltcode/src/cli/cmd/upgrade.ts index 0822b522d..b8938c4af 100644 --- a/packages/voltcode/src/cli/cmd/upgrade.ts +++ b/packages/voltcode/src/cli/cmd/upgrade.ts @@ -7,16 +7,41 @@ export const UpgradeCommand = { command: "upgrade [target]", describe: "upgrade volt to the latest or a specific version", builder: (yargs: Argv) => { - return yargs.positional("target", { - describe: "version to upgrade to, for ex '0.1.48' or 'v0.1.48'", - type: "string", - }) + return yargs + .positional("target", { + describe: "version to upgrade to, for ex '0.1.48' or 'v0.1.48'", + type: "string", + }) + .option("method", { + alias: "m", + describe: "installation method to use", + type: "string", + choices: ["curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"], + }) }, - handler: async (args: { target?: string }) => { + handler: async (args: { target?: string; method?: string }) => { UI.empty() UI.println(UI.logo(" ")) UI.empty() prompts.intro("Upgrade") + const detectedMethod = await Installation.method() + const method = (args.method as Installation.Method) ?? detectedMethod + if (method === "unknown") { + prompts.log.error(`volt is installed to ${process.execPath} and may be managed by a package manager`) + const install = await prompts.select({ + message: "Install anyways?", + options: [ + { label: "Yes", value: true }, + { label: "No", value: false }, + ], + initialValue: false, + }) + if (!install) { + prompts.outro("Done") + return + } + } + prompts.log.info("Using method: " + method) const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest() if (Installation.VERSION === target) { @@ -28,11 +53,16 @@ export const UpgradeCommand = { prompts.log.info(`From ${Installation.VERSION} → ${target}`) const spinner = prompts.spinner() spinner.start("Upgrading...") - const err = await Installation.upgrade("curl", target).catch((err) => err) + const err = await Installation.upgrade(method, target).catch((err) => err) if (err) { spinner.stop("Upgrade failed", 1) if (err instanceof Installation.UpgradeFailedError) { - prompts.log.error(err.data.stderr) + // necessary because choco only allows install/upgrade in elevated terminals + if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) { + prompts.log.error("Please run the terminal as Administrator and try again") + } else { + prompts.log.error(err.data.stderr) + } } else if (err instanceof Error) prompts.log.error(err.message) prompts.outro("Done") return diff --git a/packages/voltcode/src/cli/upgrade.ts b/packages/voltcode/src/cli/upgrade.ts index 1696e1a9a..c0c2327f0 100644 --- a/packages/voltcode/src/cli/upgrade.ts +++ b/packages/voltcode/src/cli/upgrade.ts @@ -5,7 +5,8 @@ import { Installation } from "@/installation" export async function upgrade() { const config = await Config.global() - const latest = await Installation.latest().catch(() => {}) + const method = await Installation.method() + const latest = await Installation.latest(method).catch(() => {}) if (!latest) return if (Installation.VERSION === latest) return @@ -17,7 +18,8 @@ export async function upgrade() { return } - await Installation.upgrade("curl", latest) + if (method === "unknown") return + await Installation.upgrade(method, latest) .then(() => Bus.publish(Installation.Event.Updated, { version: latest })) .catch(() => {}) } diff --git a/packages/voltcode/src/installation/index.ts b/packages/voltcode/src/installation/index.ts index 78e2c8ecb..70f6c0415 100644 --- a/packages/voltcode/src/installation/index.ts +++ b/packages/voltcode/src/installation/index.ts @@ -1,8 +1,10 @@ import { BusEvent } from "@/bus/bus-event" +import path from "path" import { $ } from "bun" import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { Log } from "../util/log" +import { iife } from "@/util/iife" import { Flag } from "../flag/flag" declare global { @@ -56,7 +58,59 @@ export namespace Installation { } export async function method() { - return "curl" as const + if (process.execPath.includes(path.join(".voltcode", "bin"))) return "curl" + if (process.execPath.includes(path.join(".local", "bin"))) return "curl" + const exec = process.execPath.toLowerCase() + + const checks = [ + { + name: "npm" as const, + command: () => $`npm list -g --depth=0`.throws(false).quiet().text(), + }, + { + name: "yarn" as const, + command: () => $`yarn global list`.throws(false).quiet().text(), + }, + { + name: "pnpm" as const, + command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text(), + }, + { + name: "bun" as const, + command: () => $`bun pm ls -g`.throws(false).quiet().text(), + }, + { + name: "brew" as const, + command: () => $`brew list --formula voltcode`.throws(false).quiet().text(), + }, + { + name: "scoop" as const, + command: () => $`scoop list voltcode`.throws(false).quiet().text(), + }, + { + name: "choco" as const, + command: () => $`choco list --limit-output voltcode`.throws(false).quiet().text(), + }, + ] + + checks.sort((a, b) => { + const aMatches = exec.includes(a.name) + const bMatches = exec.includes(b.name) + if (aMatches && !bMatches) return -1 + if (!aMatches && bMatches) return 1 + return 0 + }) + + for (const check of checks) { + const output = await check.command() + const installedName = + check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "voltcode" : "voltcode-ai" + if (output.includes(installedName)) { + return check.name + } + } + + return "unknown" } export const UpgradeFailedError = NamedError.create( @@ -66,17 +120,58 @@ export namespace Installation { }), ) - export async function upgrade(_method: Method, target: string) { - const result = await $`curl -fsSL https://www.voltropy.com/install | sh`.env({ - ...process.env, - VOLT_VERSION: target, - }).quiet().throws(false) + async function getBrewFormula() { + const tapFormula = await $`brew list --formula anomalyco/tap/voltcode`.throws(false).quiet().text() + if (tapFormula.includes("voltcode")) return "anomalyco/tap/voltcode" + const coreFormula = await $`brew list --formula voltcode`.throws(false).quiet().text() + if (coreFormula.includes("voltcode")) return "voltcode" + return "voltcode" + } + + export async function upgrade(method: Method, target: string) { + let cmd + switch (method) { + case "curl": + cmd = $`curl -fsSL https://opencode.ai/install | bash`.env({ + ...process.env, + VERSION: target, + }) + break + case "npm": + cmd = $`npm install -g voltcode-ai@${target}` + break + case "pnpm": + cmd = $`pnpm install -g voltcode-ai@${target}` + break + case "bun": + cmd = $`bun install -g voltcode-ai@${target}` + break + case "brew": { + const formula = await getBrewFormula() + cmd = $`brew upgrade ${formula}`.env({ + HOMEBREW_NO_AUTO_UPDATE: "1", + ...process.env, + }) + break + } + case "choco": + cmd = $`echo Y | choco upgrade voltcode --version=${target}` + break + case "scoop": + cmd = $`scoop install voltcode@${target}` + break + default: + throw new Error(`Unknown method: ${method}`) + } + const result = await cmd.quiet().throws(false) if (result.exitCode !== 0) { + const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8") throw new UpgradeFailedError({ - stderr: result.stderr.toString("utf8"), + stderr: stderr, }) } log.info("upgraded", { + method, target, stdout: result.stdout.toString(), stderr: result.stderr.toString(), @@ -88,16 +183,64 @@ export namespace Installation { export const CHANNEL = typeof VOLTCODE_CHANNEL === "string" ? VOLTCODE_CHANNEL : "local" export const USER_AGENT = `voltcode/${CHANNEL}/${VERSION}/${Flag.VOLTCODE_CLIENT}` - export async function latest(_installMethod?: Method) { - const platform = process.platform === "darwin" ? "darwin" : "linux" - const arch = process.arch === "arm64" ? "arm64" : "amd64" - const res = await fetch("https://api.voltropy.com/v1/bootstrap/download-url", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ platform, arch, version: "latest" }), - }) - if (!res.ok) throw new Error(res.statusText) - const data: any = await res.json() - return (data.version as string).replace(/^v/, "") + export async function latest(installMethod?: Method) { + const detectedMethod = installMethod || (await method()) + + if (detectedMethod === "brew") { + const formula = await getBrewFormula() + if (formula === "voltcode") { + return fetch("https://formulae.brew.sh/api/formula/opencode.json") + .then((res) => { + if (!res.ok) throw new Error(res.statusText) + return res.json() + }) + .then((data: any) => data.versions.stable) + } + } + + if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { + const registry = await iife(async () => { + const r = (await $`npm config get registry`.quiet().nothrow().text()).trim() + const reg = r || "https://registry.npmjs.org" + return reg.endsWith("/") ? reg.slice(0, -1) : reg + }) + const channel = CHANNEL + return fetch(`${registry}/voltcode-ai/${channel}`) + .then((res) => { + if (!res.ok) throw new Error(res.statusText) + return res.json() + }) + .then((data: any) => data.version) + } + + if (detectedMethod === "choco") { + return fetch( + "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version", + { headers: { Accept: "application/json;odata=verbose" } }, + ) + .then((res) => { + if (!res.ok) throw new Error(res.statusText) + return res.json() + }) + .then((data: any) => data.d.results[0].Version) + } + + if (detectedMethod === "scoop") { + return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", { + headers: { Accept: "application/json" }, + }) + .then((res) => { + if (!res.ok) throw new Error(res.statusText) + return res.json() + }) + .then((data: any) => data.version) + } + + return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest") + .then((res) => { + if (!res.ok) throw new Error(res.statusText) + return res.json() + }) + .then((data: any) => data.tag_name.replace(/^v/, "")) } } From 92ac39a2f90858e5f2235b0353c6a3d004523d74 Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 15:37:29 -0800 Subject: [PATCH 07/18] bump: track upgrade refactor restoration as volt-da4 Co-Authored-By: Claude Opus 4.6 --- .pebbles/events.jsonl | 5 +++++ .pebbles/pebbles.db | Bin 102400 -> 102400 bytes 2 files changed, 5 insertions(+) diff --git a/.pebbles/events.jsonl b/.pebbles/events.jsonl index 398c7a9d7..3290d7e70 100644 --- a/.pebbles/events.jsonl +++ b/.pebbles/events.jsonl @@ -36,3 +36,8 @@ {"type":"dep_add","timestamp":"2026-02-25T23:22:42.728294Z","issue_id":"volt-156","payload":{"dep_type":"blocks","depends_on":"volt-156.7"}} {"type":"dep_add","timestamp":"2026-02-25T23:22:42.75862Z","issue_id":"volt-156","payload":{"dep_type":"blocks","depends_on":"volt-156.8"}} {"type":"dep_add","timestamp":"2026-02-25T23:22:42.792294Z","issue_id":"volt-156","payload":{"dep_type":"blocks","depends_on":"volt-156.9"}} +{"type":"status_update","timestamp":"2026-02-25T23:31:48.24282Z","issue_id":"volt-156.6","payload":{"status":"in_progress"}} +{"type":"status_update","timestamp":"2026-02-25T23:31:53.700631Z","issue_id":"volt-156.1","payload":{"status":"in_progress"}} +{"type":"close","timestamp":"2026-02-25T23:32:31.153851Z","issue_id":"volt-156.6","payload":{}} +{"type":"close","timestamp":"2026-02-25T23:33:02.263939Z","issue_id":"volt-156.1","payload":{}} +{"type":"create","timestamp":"2026-02-25T23:35:15.248281Z","issue_id":"volt-da4","payload":{"description":"## Background\n\nCommit 70a5534e0 (\"refactor: replace upstream upgrade sources with voltropy install\nscript\") was reverted from the fix/lcm-inline-content-path-resolution branch because\nit was unrelated to the LCM bug fix. The commit is preserved in git history.\n\nThat commit did the right thing architecturally — it replaced ~170 lines of\npackage-manager sniffing (npm, brew, choco, scoop, pnpm, bun, yarn) and their\nrespective version-check endpoints (GitHub releases, npm registry, Homebrew formulae,\nChocolatey API, Scoop manifests) with a single install script and version endpoint.\n\nBut it pointed at Voltropy infrastructure (voltropy.com/install, api.voltropy.com).\nVoltropy is handing the product off to Martian Engineering, so the endpoints need\nto be updated before this lands.\n\n## Required change\n\n1. Cherry-pick 70a5534e0 onto a new branch (off dev, after the LCM fix merges).\n2. Replace all Voltropy references with Martian Engineering equivalents:\n - `https://www.voltropy.com/install` → whatever the Martian Engineering install\n script URL will be\n - `https://api.voltropy.com/v1/bootstrap/download-url` → whatever the Martian\n Engineering version API will be\n - Any other voltropy.com references in the cherry-picked code\n3. Update the env var from VOLT_VERSION to whatever is appropriate (check if the\n new install script expects the same var name).\n\n## Open questions (must be answered before implementation)\n\n- What are the Martian Engineering URLs for the install script and version API?\n- Is the install script API-compatible with the Voltropy one, or does it need\n adaptation?\n- Do we want to keep the \"curl | sh\" pattern or use something else?\n\n## Reference\n\n- Original commit: 70a5534e0\n- View it: git show 70a5534e0\n- Revert commit: b9e12ed5f (on fix/lcm-inline-content-path-resolution)\n\n## Acceptance criteria\n\n- Separate PR on its own branch, not bundled with anything else.\n- All voltropy.com references replaced with Martian Engineering endpoints.\n- The upgrade command works end-to-end (or is clearly marked as needing\n infrastructure that doesn't exist yet).\n- Typecheck passes.","priority":"3","title":"Restore upgrade refactor as separate PR with Martian Engineering branding","type":"task"}} diff --git a/.pebbles/pebbles.db b/.pebbles/pebbles.db index 970ee35fa8207bd5c8b30983b9b2965f6df29742..05a6efdfd7d7a05d71af5b6d0290a347fe738ac4 100644 GIT binary patch delta 2422 zcmZ`*O>7%Q7z(b) z+ODO7n+sH2pco0M7Xn4)hFbZ;365Mjaiv5>2nh+0D3@M%v$m7ew2{2K*_r*mH}Adg zo4K*sb7QmT<3rEiwjzx)FXe!mq>?zwHr=Nl}_^?(=vZGe=>hCzcufg zE9PhBO;gs7&`Gn#5-$z(caC(Fgd4}e-TNCeor52~v#0L} zs|^hw>{wV>sDEF6+|^^bLUFtH-Qg2ssnpy=GM+q{noZ5Vot#LXj7=nC$?273YVL`l zdPXgE)gN6qhwgO!xUZ+9cQDdBcz>g-cjT7n>mS(|{US9yj0^)UENQL+A#3xaL$5ML ziu+8mfYWlC%1)4{i%bR%^Jvk_IUeWI@p6=r%(L-u>=>P8)_P8gqG#8>d~jud86hMu zaT#!%vQiXk`+k|Ka*GueH=Ka>Sq za^GbZBEB~{GeIsErATBA9}8hQ9xE^pH%ZEKvRNHx%<~ITESZ<1WaWh=qNFS#{3!X2 zk)ju+3X|SAffWtIkp^J`OO%x;of8GF(^6K*LeXVBYAopdn!5_kFQ1RnB@KiC*~vmhHQtbnkpgg+gEJLi z=~V!hjvs{hpp8bejRQ=cErRQSLRjDhw64OZQuq}_$Vvt{MbIcYjcL>9fjA^eG{Z-U z`A&TI$avh?iJ+*K-kOQ;tQ#(}4M?BiRP^o82pNtwaxI)97c8pU)q{ts3wbVOCFVPz zZDxW^Pp78%1PKq{GV(x-_Mw%hF_F!Z%}W514Io|1Q}7cNxXf_{K*>0z8_E+X*;%3F zSuVL}!3Ax8ukIM0UoAQ%=5ok3XAn`0*78B%tGPs?TrNMc_7*E^wDtZcRE9})IfR&= zR!U6{bx>-_bUA$iSlp@$irTWZGo2_+CNe?kfLV`$)7_9ka7y7)C+YAVd4SOFD>G#(zH zx`7t8Uhj;cG|g}Ld4*zqtyuJ!p^MnqWr{YI@|}gKg`VX$t1U@v#3{3_xeO_bmVBq zcx~wjGQ*kP}{R7C0KCfMVEVT&NYY!@SB4?l_>#!?n+{=yu0%EAXBAu5NcH z9cu-CF8Vpi?8a84bu0_Hu5{Ck1tNwU79sTpWubRVY%T>R*LCkyp)_c}Fl$@$478WD zfZp+d_%(ESs_+0NjZrQ8=l)u7;J{RBdTw$$o}4lyleU;V9n-;DOmM!Gg5v$r;4gE#G795+51Z5}u52OZnT4d>yB tYqQ%_I=%breoio?!T7n-J6fiyUQJC{sVa=9mD_t delta 341 zcmZozz}B#UZGx20Om7AT21y|1fnb)?6LpN3W_oW-Si&#F%KscF%D~0{oBtdCNB%eb z&o?&uGjC26uH~_^G>I}Y07~+40ttS0AR)>MB)D0C1P3!0e?3rJA~XLN{sR7|{MY!8 z^Y7qa#-GSPoxfwVVuCCGxaRD(O5c31ED3k{ALE^ldBQ~s)-^@1QA3p~ZLl^_g0*0`Ojb3~m?(9Iz y`{kjQyI){rnSA2B@Z{Jl(ohFjni-lK8f~6`C5?MA>xY2FtPcdXvj#B!wg&+9f>Wyi From 2d3a293d9fba178b436cca8c1713a3034f166442 Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 15:33:09 -0800 Subject: [PATCH 08/18] volt-156.1: DRY LCM internal tools constant in session TUI --- .../voltcode/src/cli/cmd/tui/routes/session/index.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/voltcode/src/cli/cmd/tui/routes/session/index.tsx b/packages/voltcode/src/cli/cmd/tui/routes/session/index.tsx index d89234c26..c8859c5e5 100644 --- a/packages/voltcode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/voltcode/src/cli/cmd/tui/routes/session/index.tsx @@ -88,6 +88,8 @@ import "opentui-spinner/solid" addDefaultParsers(parsers.parsers) +const LCM_INTERNAL_TOOLS = ["lcm_expand", "lcm_grep", "lcm_read"] + class CustomSpeedScroll implements ScrollAcceleration { constructor(private speed: number) {} @@ -1558,9 +1560,6 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las const { theme } = useTheme() const ctx = use() - // Internal LCM tools that are always hidden (not in dev mode) - const LCM_INTERNAL_TOOLS = ["lcm_expand", "lcm_grep", "lcm_read"] - // Check if there are hidden tools with no visible content const hasHiddenToolsOnly = createMemo(() => { // Check if there are any tool parts @@ -1583,6 +1582,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las .filter((p) => p.type === "tool") .every((p) => { const toolPart = p as ToolPart + // See LCM_INTERNAL_TOOLS definition above // Internal LCM tools are always hidden when not in dev mode if (!ctx.devMode() && LCM_INTERNAL_TOOLS.includes(toolPart.tool)) return true // Other tools are hidden when showDetails=false and completed @@ -1836,12 +1836,10 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess const ctx = use() const sync = useSync() - // Internal LCM tools that should only be visible in dev mode - const LCM_INTERNAL_TOOLS = ["lcm_expand", "lcm_grep", "lcm_read"] - // Hide tool if showDetails is false and tool completed successfully // Hide internal LCM tools (lcm_expand, lcm_grep) when not in dev mode const shouldHide = createMemo(() => { + // See LCM_INTERNAL_TOOLS definition above // Hide internal LCM tools when not in dev mode if (!ctx.devMode() && LCM_INTERNAL_TOOLS.includes(props.part.tool)) return true if (ctx.showDetails()) return false From da5541bd4a783babec408afaf5c79cd6d41ea0cc Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 15:52:14 -0800 Subject: [PATCH 09/18] bump: track LongMemEval benchmark as volt-pebble Co-Authored-By: Claude Opus 4.6 --- .pebbles/events.jsonl | 3 +++ .pebbles/pebbles.db | Bin 102400 -> 102400 bytes 2 files changed, 3 insertions(+) diff --git a/.pebbles/events.jsonl b/.pebbles/events.jsonl index 3290d7e70..c0320e390 100644 --- a/.pebbles/events.jsonl +++ b/.pebbles/events.jsonl @@ -41,3 +41,6 @@ {"type":"close","timestamp":"2026-02-25T23:32:31.153851Z","issue_id":"volt-156.6","payload":{}} {"type":"close","timestamp":"2026-02-25T23:33:02.263939Z","issue_id":"volt-156.1","payload":{}} {"type":"create","timestamp":"2026-02-25T23:35:15.248281Z","issue_id":"volt-da4","payload":{"description":"## Background\n\nCommit 70a5534e0 (\"refactor: replace upstream upgrade sources with voltropy install\nscript\") was reverted from the fix/lcm-inline-content-path-resolution branch because\nit was unrelated to the LCM bug fix. The commit is preserved in git history.\n\nThat commit did the right thing architecturally — it replaced ~170 lines of\npackage-manager sniffing (npm, brew, choco, scoop, pnpm, bun, yarn) and their\nrespective version-check endpoints (GitHub releases, npm registry, Homebrew formulae,\nChocolatey API, Scoop manifests) with a single install script and version endpoint.\n\nBut it pointed at Voltropy infrastructure (voltropy.com/install, api.voltropy.com).\nVoltropy is handing the product off to Martian Engineering, so the endpoints need\nto be updated before this lands.\n\n## Required change\n\n1. Cherry-pick 70a5534e0 onto a new branch (off dev, after the LCM fix merges).\n2. Replace all Voltropy references with Martian Engineering equivalents:\n - `https://www.voltropy.com/install` → whatever the Martian Engineering install\n script URL will be\n - `https://api.voltropy.com/v1/bootstrap/download-url` → whatever the Martian\n Engineering version API will be\n - Any other voltropy.com references in the cherry-picked code\n3. Update the env var from VOLT_VERSION to whatever is appropriate (check if the\n new install script expects the same var name).\n\n## Open questions (must be answered before implementation)\n\n- What are the Martian Engineering URLs for the install script and version API?\n- Is the install script API-compatible with the Voltropy one, or does it need\n adaptation?\n- Do we want to keep the \"curl | sh\" pattern or use something else?\n\n## Reference\n\n- Original commit: 70a5534e0\n- View it: git show 70a5534e0\n- Revert commit: b9e12ed5f (on fix/lcm-inline-content-path-resolution)\n\n## Acceptance criteria\n\n- Separate PR on its own branch, not bundled with anything else.\n- All voltropy.com references replaced with Martian Engineering endpoints.\n- The upgrade command works end-to-end (or is clearly marked as needing\n infrastructure that doesn't exist yet).\n- Typecheck passes.","priority":"3","title":"Restore upgrade refactor as separate PR with Martian Engineering branding","type":"task"}} +{"type":"status_update","timestamp":"2026-02-25T23:47:00.068803Z","issue_id":"volt-156.4","payload":{"status":"in_progress"}} +{"type":"close","timestamp":"2026-02-25T23:47:47.03211Z","issue_id":"volt-156.4","payload":{}} +{"type":"create","timestamp":"2026-02-25T23:52:14.79731Z","issue_id":"volt-5eb","payload":{"description":"## Context\n\nLongMemEval (https://github.com/xiaowu0162/LongMemEval) is a benchmark for\nevaluating chat assistants on long-term interactive memory. Published at ICLR 2025.\nPaper: \"Benchmarking Chat Assistants on Long-Term Interactive Memory.\"\n\nVolt's LCM (Lossless Context Management) is specifically designed to preserve and\nretrieve context across long conversations — summaries, large file storage,\nconversation ancestry. This benchmark is a natural fit for measuring how well\nLCM actually performs at long-term recall.\n\n## Required work\n\n1. Read the LongMemEval repo and understand the evaluation protocol, task categories,\n and metrics.\n2. Determine what adapter/harness is needed to run Volt against the benchmark.\n3. Implement the adapter.\n4. Run the eval and report results.\n\n## Open questions\n\n- What conversation lengths does the benchmark test? Does it exceed Volt's\n compaction thresholds?\n- Does the benchmark require multi-session memory or single-session only?\n- What are baseline scores for comparable systems?\n\n## Reference\n\n- Repo: https://github.com/xiaowu0162/LongMemEval\n- Paper venue: ICLR 2025","priority":"4","title":"Run LongMemEval benchmark against Volt's LCM","type":"task"}} diff --git a/.pebbles/pebbles.db b/.pebbles/pebbles.db index 05a6efdfd7d7a05d71af5b6d0290a347fe738ac4..0266cde91adf0b25711cb9cdd5cff62fe3bc84c8 100644 GIT binary patch delta 1560 zcma)6O=ufO6qX$SC}UY;3Q9z~s)xAxSeF8GihOb>0L6k>E}kG=I;C=_}v6nYDk-coSitmB^)3Kh(<@O-IP9Q~EQR%Uc|>7u(ZL=Qed zkCA?Q6fQ8H=8F981^ySm$A9O)@E`g2{7Zh9@AHp1a@`w)3BI>lo#>y%R%Z~Mf4P^)XKv)pQgeJ8@S_4W#M3pKQbqpcZ*21^pCPIv{(mNh3C#@(n0k(l6 z7v>jNpgvWf_1LoLqHe&oH&05r^z$x#)1^Pn?!Z-6UG{}@*_SZQ(qCijj6U30RK`ST z%!v%JB&6tIjM8SzOc(udJ@iEs?LZq%*pURxD(Gr7s0p@^ZKl!c5b2M9EG7tFQ{s%M z+uKBKL~27N4g9wI8JHv{twJ=D5DDEuSPvrvW0j`blk6nI1yFt;jdjAT1|;>F(3$R1 z*hG_I>!EeZlZe73+9?yLE!e^+Vq`Pnu}PY7APwnWOrF_uK4^3dJ;taZR`7ljYGS#i z^af)!k9Guc4iL`y4jQ}4k%vUKi5|&+eCNb_g5K52DqlsDV1?O8!50=gD&x%nvf$V$ z=NpgJJ-CCeCqjv^l{HOUbZMg+2rZpM#8)D=kLB0Y)y~J$o_6p~wei@r2McjG%8bmQ zi*}l&^Vkd#qG%W3ux2nE8EuJ^Ng_*VoDOeyQNsO%#CN_iR)KdMtQX9QPYzS872*d;~tQZo* zg&ofekm-i}j_~wA$!N!A%HRr3x(I_&n3Ax@S5zzOlnj6tE!TKE#$rs>kL_PaO(TYm z@e1kEfdBn5ponZrVH0J7jkD=>{@+q0Xg|&J&B|20QlDL|Pd8@kjoOSicWZ9C_TGP= z^h#g-Szsgd@yi!#xkBym!9=0-$>PB9*v{u4makl#Z#H}H{}B!CfBj0nwDL2nyH6!wzXP1Z@KYLyD-fL>Ig5UAI#T_=q>KyXo2p{fz25{iW4@$5JrWj(X( z%sR0+xH%vJ7h2_Dsp60m7lhQvhj8qTD<>p=01~}W32}l*;fd`^!@!XZCERjfL@R`CVok)zocygog%2Re&{f;>4-qht^$jY>fd+sC-Gna56 zNgQU!ahMo$Nhgse>tp0k!pN`WXYwQYfqYBWNtb*t+yyhQ`3 zQ$~HhfBbo-6LGDA*a#)B!$(Yo+_mBDk>DC{2X0)`%AiKsHpkP$G@k2ThxcL;_)t-@`(VMD?0mp^2z*y)V-ZHP3^{X5 z*AA7CMuRrAhB@C5k_EAE&r}1}+~m6&WZV66>Uv1su=qURPp`0!j*)QPr)ifoh>q3i-c7Vcml z2EReaVW=Vls$S1p`S`8@8nH&lA_hur$0580fsmYTaX@ujJkm7q20#zP9U2X4DT7oO zP*rPZwH%=QWel<>Z5?9P0V328$yGjdb@kP=)BtP zq?{|AI(8gui!NNSQM2N?nlg<+v7Vd#53gF!^;^^ARHB{hanKLX(lA7lk)VcKrr4f7 zj}g2L1bqLjp@wS%Xc~LpHJln|)NMmE@TNelp=Lt^oZsy3?{7MDtA+B-75lfdbgQ^g i$QPEf`9ii(TF>XpCHxk=+r`_(mD2xg9i41CPW~Utqu^-( From 64d2f17d5c188df6a580777bda506484cfa51e3d Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 15:47:53 -0800 Subject: [PATCH 10/18] volt-156.4: remove legacy getLargeFileContent fallback --- packages/voltcode/src/session/lcm/db.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/voltcode/src/session/lcm/db.ts b/packages/voltcode/src/session/lcm/db.ts index aa4cef5ce..d94f4649a 100644 --- a/packages/voltcode/src/session/lcm/db.ts +++ b/packages/voltcode/src/session/lcm/db.ts @@ -2016,18 +2016,6 @@ export namespace LcmDb { return { content, truncated: false, totalSize } } - // Backward-compatibility fallback for rows that predate storage_kind enforcement. - if (row.original_path) { - const file = Bun.file(row.original_path) - const exists = await file.exists() - if (!exists) { - log.warn("legacy large file path not found on disk", { fileId, path: row.original_path }) - return null - } - const content = await file.text() - return { content, truncated: false, totalSize: content.length } - } - return null } From 3b5040394ff0b072c84e8bf73640352acd6b2ff7 Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 16:02:18 -0800 Subject: [PATCH 11/18] volt-156.5: make large_files check-constraint migration atomic --- packages/voltcode/src/session/lcm/db.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/voltcode/src/session/lcm/db.ts b/packages/voltcode/src/session/lcm/db.ts index d94f4649a..f5935a6fa 100644 --- a/packages/voltcode/src/session/lcm/db.ts +++ b/packages/voltcode/src/session/lcm/db.ts @@ -541,15 +541,16 @@ export namespace LcmDb { ) THEN EXECUTE 'ALTER TABLE large_files DROP CONSTRAINT large_files_storage_shape_check'; END IF; + + BEGIN + ALTER TABLE large_files + ADD CONSTRAINT large_files_storage_shape_check CHECK ( + (storage_kind = 'path' AND original_path IS NOT NULL AND content IS NULL AND binary_content IS NULL) OR + (storage_kind = 'inline_text' AND content IS NOT NULL AND binary_content IS NULL) OR + (storage_kind = 'inline_binary' AND binary_content IS NOT NULL AND content IS NULL) + ); + EXCEPTION WHEN duplicate_object THEN NULL; END; END $$; - DO $$ BEGIN - ALTER TABLE large_files - ADD CONSTRAINT large_files_storage_shape_check CHECK ( - (storage_kind = 'path' AND original_path IS NOT NULL AND content IS NULL AND binary_content IS NULL) OR - (storage_kind = 'inline_text' AND content IS NOT NULL AND binary_content IS NULL) OR - (storage_kind = 'inline_binary' AND binary_content IS NOT NULL AND content IS NULL) - ); - EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE INDEX IF NOT EXISTS large_files_conv_idx ON large_files(conversation_id); CREATE INDEX IF NOT EXISTS large_files_path_idx ON large_files(original_path); From 0be0e1a001bead3af04303442101c54ada36f623 Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 16:03:10 -0800 Subject: [PATCH 12/18] volt-156.5: skip redundant large_files check rewrites --- packages/voltcode/src/session/lcm/db.ts | 59 ++++++++++++++++++------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/voltcode/src/session/lcm/db.ts b/packages/voltcode/src/session/lcm/db.ts index f5935a6fa..cd5b0407c 100644 --- a/packages/voltcode/src/session/lcm/db.ts +++ b/packages/voltcode/src/session/lcm/db.ts @@ -532,24 +532,51 @@ export namespace LcmDb { EXCEPTION WHEN others THEN NULL; END $$; -- Migration: enforce coherent large_files row shape by storage_kind - DO $$ BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conname = 'large_files_storage_shape_check' - AND conrelid = 'large_files'::regclass - ) THEN - EXECUTE 'ALTER TABLE large_files DROP CONSTRAINT large_files_storage_shape_check'; + DO $$ DECLARE + current_def text; + normalized_current_def text; + normalized_desired_def text := regexp_replace( + lower( + 'CHECK ( + (storage_kind = ''path'' AND original_path IS NOT NULL AND content IS NULL AND binary_content IS NULL) OR + (storage_kind = ''inline_text'' AND content IS NOT NULL AND binary_content IS NULL) OR + (storage_kind = ''inline_binary'' AND binary_content IS NOT NULL AND content IS NULL) + )' + ), + '[[:space:]()]', + '', + 'g' + ); + BEGIN + SELECT pg_get_constraintdef(oid) + INTO current_def + FROM pg_constraint + WHERE conname = 'large_files_storage_shape_check' + AND conrelid = 'large_files'::regclass; + + IF current_def IS NOT NULL THEN + normalized_current_def := regexp_replace( + lower(replace(current_def, '::text', '')), + '[[:space:]()]', + '', + 'g' + ); END IF; - BEGIN - ALTER TABLE large_files - ADD CONSTRAINT large_files_storage_shape_check CHECK ( - (storage_kind = 'path' AND original_path IS NOT NULL AND content IS NULL AND binary_content IS NULL) OR - (storage_kind = 'inline_text' AND content IS NOT NULL AND binary_content IS NULL) OR - (storage_kind = 'inline_binary' AND binary_content IS NOT NULL AND content IS NULL) - ); - EXCEPTION WHEN duplicate_object THEN NULL; END; + IF current_def IS NULL OR normalized_current_def != normalized_desired_def THEN + IF current_def IS NOT NULL THEN + EXECUTE 'ALTER TABLE large_files DROP CONSTRAINT large_files_storage_shape_check'; + END IF; + + BEGIN + ALTER TABLE large_files + ADD CONSTRAINT large_files_storage_shape_check CHECK ( + (storage_kind = 'path' AND original_path IS NOT NULL AND content IS NULL AND binary_content IS NULL) OR + (storage_kind = 'inline_text' AND content IS NOT NULL AND binary_content IS NULL) OR + (storage_kind = 'inline_binary' AND binary_content IS NOT NULL AND content IS NULL) + ); + EXCEPTION WHEN duplicate_object THEN NULL; END; + END IF; END $$; CREATE INDEX IF NOT EXISTS large_files_conv_idx ON large_files(conversation_id); From 64383c7a48858b7a90ba7b13f0d0026ec653ac17 Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 16:13:01 -0800 Subject: [PATCH 13/18] volt-156.3: type metadata.lcm escape hatch between lcm_read and processor --- packages/voltcode/src/session/lcm/index.ts | 1 + packages/voltcode/src/session/lcm/types.ts | 9 +++++++++ packages/voltcode/src/session/processor.ts | 3 ++- packages/voltcode/src/tool/lcm-read.ts | 2 ++ 4 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 packages/voltcode/src/session/lcm/types.ts diff --git a/packages/voltcode/src/session/lcm/index.ts b/packages/voltcode/src/session/lcm/index.ts index 419e5ed1b..218f5a8aa 100644 --- a/packages/voltcode/src/session/lcm/index.ts +++ b/packages/voltcode/src/session/lcm/index.ts @@ -17,5 +17,6 @@ export * from "./condense" export * from "./context" export * from "./db" export * from "./large-file-threshold" +export * from "./types" export * from "./explore" export * from "./migration" diff --git a/packages/voltcode/src/session/lcm/types.ts b/packages/voltcode/src/session/lcm/types.ts new file mode 100644 index 000000000..60f5461f3 --- /dev/null +++ b/packages/voltcode/src/session/lcm/types.ts @@ -0,0 +1,9 @@ +/** + * Escape-hatch metadata used by tools that return content already stored in LCM. + * The processor checks this to avoid re-storing tool output back into LCM. + */ +export interface LcmToolMetadata { + storedInLcm: boolean + fileId: string + originalTokenCount?: number +} diff --git a/packages/voltcode/src/session/processor.ts b/packages/voltcode/src/session/processor.ts index 42155f0ec..2f231c88d 100644 --- a/packages/voltcode/src/session/processor.ts +++ b/packages/voltcode/src/session/processor.ts @@ -18,6 +18,7 @@ import { Question } from "@/question" import { ReadCoordinator } from "@/tool/read" import { Flag } from "@/flag/flag" import { handleLargeToolOutput, LARGE_TOOL_OUTPUT_THRESHOLD } from "./large-tool-output" +import type { LcmToolMetadata } from "./lcm/types" import { Token } from "@/util/token" export namespace SessionProcessor { @@ -531,7 +532,7 @@ export namespace SessionProcessor { ) // Handle large tool outputs by storing in LCM let finalOutput = toolResult.output - let lcmMetadata = toolResult.metadata?.lcm + let lcmMetadata: LcmToolMetadata | undefined = toolResult.metadata?.lcm if (typeof toolResult.output === "string") { const outputTokens = Token.estimate(toolResult.output) diff --git a/packages/voltcode/src/tool/lcm-read.ts b/packages/voltcode/src/tool/lcm-read.ts index 1914714c4..9a01561e6 100644 --- a/packages/voltcode/src/tool/lcm-read.ts +++ b/packages/voltcode/src/tool/lcm-read.ts @@ -1,6 +1,7 @@ import z from "zod" import { Tool } from "./tool" import { LcmDb } from "../session/lcm/db" +import type { LcmToolMetadata } from "../session/lcm/types" import { Session } from "../session" import { SessionPrompt } from "../session/prompt" import { Log } from "../util/log" @@ -26,6 +27,7 @@ interface LcmReadMetadata { truncated: boolean totalSize: number storageKind?: string + lcm?: LcmToolMetadata } export const LcmReadTool = Tool.define("lcm_read", { From 198172318ff0bda9cb060375f74231fbfa8ec055 Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 16:19:17 -0800 Subject: [PATCH 14/18] volt-156.8: add storage_kind migration for tenant schemas --- .../voltcode/src/session/lcm/user-context.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/packages/voltcode/src/session/lcm/user-context.ts b/packages/voltcode/src/session/lcm/user-context.ts index 27ca90045..317078cf9 100644 --- a/packages/voltcode/src/session/lcm/user-context.ts +++ b/packages/voltcode/src/session/lcm/user-context.ts @@ -237,6 +237,93 @@ export async function ensureUserSchema(conn: postgres.Sql, userId: string): Prom ) ); + -- Migration: drop the old content check constraint to allow path-only storage (avoid NOTICE spam) + DO $$ BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'large_file_content_check' + AND conrelid = 'large_files'::regclass + ) THEN + EXECUTE 'ALTER TABLE large_files DROP CONSTRAINT large_file_content_check'; + END IF; + END $$; + + -- Migration: add storage_kind for explicit payload mode (path/inline_text/inline_binary) + DO $$ BEGIN + ALTER TABLE large_files ADD COLUMN storage_kind text; + EXCEPTION WHEN duplicate_column THEN NULL; END $$; + + -- Migration: backfill storage_kind from existing data + UPDATE large_files + SET storage_kind = CASE + WHEN content IS NOT NULL THEN 'inline_text' + WHEN binary_content IS NOT NULL THEN 'inline_binary' + ELSE 'path' + END + WHERE storage_kind IS NULL; + + -- Migration: default + NOT NULL for storage_kind + DO $$ BEGIN + ALTER TABLE large_files ALTER COLUMN storage_kind SET DEFAULT 'path'; + EXCEPTION WHEN others THEN NULL; END $$; + DO $$ BEGIN + ALTER TABLE large_files ALTER COLUMN storage_kind SET NOT NULL; + EXCEPTION WHEN others THEN NULL; END $$; + + -- Migration: original_path is optional for inline payloads + DO $$ BEGIN + ALTER TABLE large_files ALTER COLUMN original_path DROP NOT NULL; + EXCEPTION WHEN others THEN NULL; END $$; + + -- Migration: enforce coherent large_files row shape by storage_kind + DO $$ DECLARE + current_def text; + normalized_current_def text; + normalized_desired_def text := regexp_replace( + lower( + 'CHECK ( + (storage_kind = ''path'' AND original_path IS NOT NULL AND content IS NULL AND binary_content IS NULL) OR + (storage_kind = ''inline_text'' AND content IS NOT NULL AND binary_content IS NULL) OR + (storage_kind = ''inline_binary'' AND binary_content IS NOT NULL AND content IS NULL) + )' + ), + '[[:space:]()]', + '', + 'g' + ); + BEGIN + SELECT pg_get_constraintdef(oid) + INTO current_def + FROM pg_constraint + WHERE conname = 'large_files_storage_shape_check' + AND conrelid = 'large_files'::regclass; + + IF current_def IS NOT NULL THEN + normalized_current_def := regexp_replace( + lower(replace(current_def, '::text', '')), + '[[:space:]()]', + '', + 'g' + ); + END IF; + + IF current_def IS NULL OR normalized_current_def != normalized_desired_def THEN + IF current_def IS NOT NULL THEN + EXECUTE 'ALTER TABLE large_files DROP CONSTRAINT large_files_storage_shape_check'; + END IF; + + BEGIN + ALTER TABLE large_files + ADD CONSTRAINT large_files_storage_shape_check CHECK ( + (storage_kind = 'path' AND original_path IS NOT NULL AND content IS NULL AND binary_content IS NULL) OR + (storage_kind = 'inline_text' AND content IS NOT NULL AND binary_content IS NULL) OR + (storage_kind = 'inline_binary' AND binary_content IS NOT NULL AND content IS NULL) + ); + EXCEPTION WHEN duplicate_object THEN NULL; END; + END IF; + END $$; + CREATE INDEX IF NOT EXISTS large_files_conv_idx ON large_files(conversation_id); CREATE INDEX IF NOT EXISTS large_files_path_idx ON large_files(original_path); From 57d2d888337b0ecfa1e4f116ca5aefefba154593 Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 16:49:48 -0800 Subject: [PATCH 15/18] volt-156.2: remove dead storageKind field from lcm_read metadata - Remove the unused storageKind property from LcmReadMetadata\n- Keep the metadata interface aligned with actual lcm_read payloads --- packages/voltcode/src/tool/lcm-read.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/voltcode/src/tool/lcm-read.ts b/packages/voltcode/src/tool/lcm-read.ts index 9a01561e6..f04f25703 100644 --- a/packages/voltcode/src/tool/lcm-read.ts +++ b/packages/voltcode/src/tool/lcm-read.ts @@ -26,7 +26,6 @@ interface LcmReadMetadata { found: boolean truncated: boolean totalSize: number - storageKind?: string lcm?: LcmToolMetadata } From 5ad77e0026caa4fe6e17a85f035592d5dbb88027 Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 16:49:55 -0800 Subject: [PATCH 16/18] volt-156.7: cap lcm_read max_bytes schema at 100MB - Add a .max(100_000_000) guard to the max_bytes Zod schema\n- Keep default byte behavior unchanged while enforcing upper limit --- packages/voltcode/src/tool/lcm-read.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/voltcode/src/tool/lcm-read.ts b/packages/voltcode/src/tool/lcm-read.ts index f04f25703..e8285f31a 100644 --- a/packages/voltcode/src/tool/lcm-read.ts +++ b/packages/voltcode/src/tool/lcm-read.ts @@ -17,6 +17,7 @@ const parameters = z.object({ max_bytes: z .number() .min(1) + .max(100_000_000) .optional() .describe("Optional byte limit for very large payloads (default: 100000)"), }) From 2cb9210f2d67911369db025c956098d06103f7cb Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 16:50:25 -0800 Subject: [PATCH 17/18] volt-156.9: handle inline_binary reads explicitly - Add an explicit inline_binary branch in getLargeFileContent\n- Use getLargeFile in lcm_read to distinguish binary vs missing path\n- Return a binary-specific user message instead of a moved-file hint --- packages/voltcode/src/session/lcm/db.ts | 5 +++++ packages/voltcode/src/tool/lcm-read.ts | 19 ++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/voltcode/src/session/lcm/db.ts b/packages/voltcode/src/session/lcm/db.ts index cd5b0407c..d5e790f9a 100644 --- a/packages/voltcode/src/session/lcm/db.ts +++ b/packages/voltcode/src/session/lcm/db.ts @@ -2015,6 +2015,11 @@ export namespace LcmDb { } } + if (row.storage_kind === "inline_binary") { + // Binary content cannot be returned as text. + return null + } + // Path-backed payloads are loaded from disk on demand. if (row.storage_kind === "path" && row.original_path) { const file = Bun.file(row.original_path) diff --git a/packages/voltcode/src/tool/lcm-read.ts b/packages/voltcode/src/tool/lcm-read.ts index e8285f31a..6b5a4466f 100644 --- a/packages/voltcode/src/tool/lcm-read.ts +++ b/packages/voltcode/src/tool/lcm-read.ts @@ -82,9 +82,22 @@ The explore sub-agent will call lcm_read and return a focused answer.`, const result = await LcmDb.getLargeFileContent(fileId, maxBytes, conversationId ?? undefined) if (!result) { - // Distinguish "ID not found" from "file on disk missing". - const exists = await LcmDb.largeFileExists(fileId, conversationId ?? undefined) - if (exists) { + // Distinguish "ID not found", "binary content", and "file on disk missing". + const file = await LcmDb.getLargeFile(fileId, conversationId ?? undefined) + if (file) { + if (file.storage_kind === "inline_binary") { + return { + title: `LCM read: ${fileId}`, + metadata: { + fileId, + found: true, + truncated: false, + totalSize: 0, + }, + output: `File "${fileId}" contains binary content (${file.mime_type}) which cannot be displayed as text.\n\nUse lcm_describe with "${fileId}" for metadata about this file.`, + } + } + return { title: `LCM read: ${fileId}`, metadata: { From 4f68aab80a75041e081fa7fda85156bd13b5bb7a Mon Sep 17 00:00:00 2001 From: rabsef-bicyrm Date: Wed, 25 Feb 2026 20:38:32 -0800 Subject: [PATCH 18/18] fix: replace CJS require("crypto") with ESM import in lcm db - Bun 1.3.6 has a transpiler bug where adding code to a large namespace file can cause `require()` to become undefined in sibling functions - Replaced both require("crypto") calls in generateFileId and generateBinaryFileId with a top-level `import { createHash } from "crypto"` - This is the correct ESM pattern regardless of the Bun bug Co-Authored-By: Claude Opus 4.6 --- packages/voltcode/src/session/lcm/db.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/voltcode/src/session/lcm/db.ts b/packages/voltcode/src/session/lcm/db.ts index d5e790f9a..dc79a0099 100644 --- a/packages/voltcode/src/session/lcm/db.ts +++ b/packages/voltcode/src/session/lcm/db.ts @@ -1,3 +1,4 @@ +import { createHash } from "crypto" import postgres from "postgres" import z from "zod" import { Log } from "@/util/log" @@ -1723,8 +1724,7 @@ export namespace LcmDb { * Generate a deterministic file ID based on content hash and conversation ID. */ export function generateFileId(conversationId: number, content: string): string { - const hash = require("crypto") - .createHash("sha256") + const hash = createHash("sha256") .update(`${conversationId}:${content}`) .digest("hex") .slice(0, 16) @@ -1736,8 +1736,7 @@ export namespace LcmDb { * Generate a deterministic file ID for binary content scoped to a conversation. */ export function generateBinaryFileId(conversationId: number, content: Uint8Array): string { - const hash = require("crypto") - .createHash("sha256") + const hash = createHash("sha256") .update(`${conversationId}:`) .update(content) .digest("hex")