diff --git a/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/actual-complexity.json b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/actual-complexity.json new file mode 100644 index 000000000..3137d849e --- /dev/null +++ b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/actual-complexity.json @@ -0,0 +1,71 @@ +{ + "task": "Implement Codex file attachment detection for CodeMie assistants by scanning rollout JSONL files matched by CWD realpath and dispatching to the existing upload pipeline.", + "generated": "2026-08-05T00:00:00Z", + "dimensions": { + "component_scope": { + "score": 3, + "label": "M", + "affected": "codexUploadsDetector (new CLI-service module), codex-message-types (Agent-Tool type definitions), chat index.ts (CLI dispatch logic)", + "layers": "CLI-Command, CLI-Service, Agent-Tool" + }, + "requirements_clarity": { + "score": 2, + "label": "S", + "status": "Clear", + "gaps": null + }, + "technical_risk": { + "score": 3, + "label": "M", + "risk_factors": "Two-pass JSONL extraction logic, image_wrapper_pattern inline text parsing for filename resolution, local_images[] fallback, OOM-safe base64 size estimation without full buffer allocation — no exact copy-paste precedent in codebase", + "mitigation": "Additive-only change; errors silently return empty array; existing upload pipeline reused unchanged; easily rolled back" + }, + "file_change_estimate": { + "score": 2, + "label": "S", + "modified_files": 2, + "modified_file_list": [ + "src/agents/plugins/codex/codex-message-types.ts", + "src/cli/commands/assistants/chat/index.ts" + ], + "new_files": 1, + "new_file_list": [ + "src/cli/commands/assistants/chat/codexUploadsDetector.ts" + ], + "affected_dirs": [ + "src/cli/commands/assistants/chat", + "src/agents/plugins/codex" + ] + }, + "dependencies": { + "score": 1, + "label": "XS", + "new_packages": [], + "version_changes": [] + }, + "affected_layers": { + "score": 2, + "label": "S", + "layers_changed": ["CLI-Command", "Agent-Tool"], + "schema_migration": false, + "cross_system": false + } + }, + "total": 13, + "size": "S", + "band_range": "10-14", + "files_changed": 3, + "routing": "writing-plans", + "key_reasoning": [ + { + "dimension": "component_scope", + "reason": "Three components touched across two distinct layers: new codexUploadsDetector service module, extended codex-message-types type file, and modified chat index.ts dispatch. Additive and self-contained within the assistants chat subsystem." + }, + { + "dimension": "technical_risk", + "reason": "No exact pattern to copy: two-pass JSONL extraction, realpath CWD matching across multiple session roots, filename resolution via image_wrapper_pattern text parsing or local_images fallback, and an OOM-safe size guard using arithmetic rather than Buffer allocation. Logic is novel but low-stakes — all errors return empty array and the upload pipeline is unchanged." + } + ], + "red_flags_applied": [], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-check.json b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-check.json new file mode 100644 index 000000000..df579b47a --- /dev/null +++ b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-check.json @@ -0,0 +1,51 @@ +{ + "schema": 1, + "gate_id": "code-review.check", + "decision": "approve", + "confidence": "low", + "rationale": "All three major findings from the final round are resolved in the fix-up commit. CR-001: imageOnlyIndex counter now increments only for input_image blocks. CR-002: two-pass scan anchors event_msg search to records at or before the response_item position. CR-003: Buffer.from allocation removed; size estimated via base64 length math, empty data URI guard added. Confidence remains low because the round carries forward no-spec from the final round.", + "risk_flags": [], + "business_review": [], + "standards_review": [ + { + "standard": "Conventional Commits format", + "source": ".ai-run/guides/standards/git-workflow.md", + "status": "pass", + "notes": "Fix-up commit: 'fix(assistants): correct three bugs in Codex file attachment detection' — valid conventional commit format." + }, + { + "standard": "TypeScript code quality", + "source": ".ai-run/guides/standards/code-quality.md", + "status": "pass", + "notes": "Fix-up changes: no new any casts, no missing return types, all imports unchanged." + }, + { + "standard": "Security practices", + "status": "pass", + "notes": "Carried forward from final round — no changes to security surface in fix-up." + } + ], + "findings": [], + "finding_status": [ + { + "id": "CR-001", + "status": "resolved", + "notes": "imageOnlyIndex replaces localImageIndex; increment is now inside the if (block.type === 'input_image') branch." + }, + { + "id": "CR-002", + "status": "resolved", + "notes": "Two-pass scan: first pass finds response_item and records its index; second pass searches for event_msg only at indices <= responseItemIndex." + }, + { + "id": "CR-003", + "status": "resolved", + "notes": "Buffer.from removed. Size computed via Math.ceil(base64Data.length * 3 / 4) - paddingChars. Empty data URI guard (if (!base64Data)) added before size check." + }, + { + "id": "CR-004", + "status": "no_change_needed", + "notes": "Deferred — minor finding, not a blocking issue. Empty data URI guard added as part of CR-003 fix also partially addresses this." + } + ] +} diff --git a/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-final.json b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-final.json new file mode 100644 index 000000000..b3af3949b --- /dev/null +++ b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-final.json @@ -0,0 +1,77 @@ +{ + "schema": 1, + "gate_id": "code-review.final", + "decision": "request-changes", + "confidence": "low", + "rationale": "Three major correctness bugs found in codexUploadsDetector.ts: (1) localImageIndex counter is shared between input_file and input_image blocks causing filename misalignment in mixed-attachment turns; (2) the backward scan independently locates targetResponseItem and targetEventMsg, so a follow-up user message severs the filename chain from the attachment turn; (3) Buffer.from() allocates the full decoded image before the 100 MB guard, which can OOM-fail and misclassify a valid attachment as invalid base64. Confidence is low because sdlc-light has no spec artifact — no acceptance criteria were available to audit.", + "risk_flags": [], + "business_review": [], + "standards_review": [ + { + "standard": "Conventional Commits format", + "source": ".ai-run/guides/standards/git-workflow.md", + "status": "pass", + "notes": "All commits in range follow (): format. Feature commit: 'feat(assistants): implement Codex rollout-based file attachment detection'." + }, + { + "standard": "TypeScript code quality", + "source": ".ai-run/guides/standards/code-quality.md", + "status": "pass", + "notes": "Exported function has explicit return type, interfaces use PascalCase, all imports use .js extension, no bare require(). File naming deviates from kebab-case guide (codexUploadsDetector.ts vs kebab-case) but matches the pre-existing claudeUploadsDetector.ts pattern in the same directory." + }, + { + "standard": "Security practices", + "source": ".ai-run/guides/security/security-practices.md", + "status": "pass", + "notes": "No credentials or tokens logged. Base64 image data not echoed to console or logs (only metadata: fileName, mediaType, sizeKB). Data URIs sourced from trusted local Codex rollout files, not external input. Size limit enforced. No path traversal exposure (CWD from process.cwd())." + } + ], + "findings": [ + { + "id": "CR-001", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/assistants/chat/codexUploadsDetector.ts", + "location": "extractAttachments() — the localImageIndex counter, lines 202–226", + "problem": "localImageIndex is incremented for every attachment block (including input_file), but local_images in CodexEventMsg contains only image paths; when input_file blocks precede input_image blocks in the same user turn, the image-filename lookups into local_images are offset by the number of file blocks.", + "impact": "In a mixed file+image upload (e.g., user attaches a PDF and a screenshot together), every input_image block following any input_file block receives the wrong local_images entry as its filename fallback, falling through to the generic 'attachment_N' name and discarding the real filename.", + "recommendation": "Use a separate imageOnlyIndex that increments only when block.type === 'input_image', and use that counter for local_images lookups.", + "sources": ["blind", "edge-case"] + }, + { + "id": "CR-002", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/assistants/chat/codexUploadsDetector.ts", + "location": "extractAttachments() — dual backward scan, lines 166–193", + "problem": "targetResponseItem and targetEventMsg are located by two independent predicates in a single backward scan that stops when both are found; when the user sends a follow-up text message after uploading a file in the same rollout, the scan finds the follow-up's event_msg (no local_images) while still finding the attachment's response_item, severing the local_images filename chain.", + "impact": "Any session where the user types a follow-up message after uploading an attachment will produce attachments with only the generic 'attachment_N' filename instead of the real filename for images whose preceding input_text block does not contain the wrapper pattern.", + "recommendation": "After locating targetResponseItem, search backward for an event_msg only among records at a lower index than the response_item, so both records are anchored to the same conversation turn.", + "sources": ["blind", "edge-case"] + }, + { + "id": "CR-003", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/assistants/chat/codexUploadsDetector.ts", + "location": "processImageBlock() — Buffer.from call, line 139", + "problem": "Buffer.from(base64Data, 'base64') allocates the full decoded buffer (up to ~75 MB) solely to obtain .length before the size guard is applied; on memory-constrained environments this throws, and the catch block misclassifies a valid attachment as 'Invalid base64 data'.", + "impact": "A legitimate image just under the 100 MB limit can be silently dropped and logged as malformed base64 when the process is under memory pressure, with no indication that detection failed due to allocation rather than data corruption.", + "recommendation": "Pre-check size with Math.ceil(base64Data.length * 3 / 4) (minus trailing '=' chars) and bail before Buffer.from if the estimate exceeds MAX_FILE_SIZE_BYTES; only allocate for images that pass the guard.", + "sources": ["blind", "edge-case"] + }, + { + "id": "CR-004", + "severity": "minor", + "triage": "defer", + "file": "src/cli/commands/assistants/chat/codexUploadsDetector.ts", + "location": "processImageBlock(), lines 127–155", + "problem": "An image_url of the form 'data:image/png;base64,' (comma present, empty payload) passes all guards and returns a structurally valid DetectedFile with data: '' and sizeBytes: 0.", + "impact": "A downstream SDK call receives a well-formed DetectedFile whose data field is an empty string; any code that forwards this as an image will silently produce a zero-byte attachment without an error at detection time.", + "recommendation": "Add a guard after extracting base64Data — if (!base64Data) { logger.warn(…); return null; } — to reject empty data URIs.", + "sources": ["edge-case"] + } + ], + "dismissed_count": 1, + "dismissed_note": "E-003 (Claude path no longer uses --conversation-id for file detection) dismissed — this behavior change is intentional per cherry-picked commit bfb011c8 'fix(assistants): always use CODEMIE_SESSION_ID for file detection, not --conversation-id' from EPMCDME-13907, which is the documented correct behavior." +} diff --git a/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/events.jsonl b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/events.jsonl new file mode 100644 index 000000000..d2f4d32b7 --- /dev/null +++ b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/events.jsonl @@ -0,0 +1,2 @@ +{"event":"lifecycle_emission","intent":"artifact_published","artifact_kind":"plan","status":"succeeded"} +{"event":"lifecycle_emission","intent":"record_complexity_score","assessment_mode":"actual","status":"succeeded"} diff --git a/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/plan.md b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/plan.md new file mode 100644 index 000000000..10c663a14 --- /dev/null +++ b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/plan.md @@ -0,0 +1,942 @@ +# Codex File Attachments Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend CodeMie assistant file attachment detection to support Codex rollout JSONL, achieving feature parity with the existing Claude session-based detection. + +**Architecture:** A new `codexUploadsDetector.ts` discovers the active Codex rollout by scanning `~/.codex/sessions/**/*.jsonl` via `getCodexDiscoverySessionRoots()` + CWD realpath matching, then extracts `input_image`/`input_file` blocks from the user `response_item` record. `chat/index.ts` gains an `CODEMIE_AGENT`-aware dispatch branch. A shared `uploads-types.ts` holds the `DetectedFile` interface to avoid import coupling. + +**Tech Stack:** TypeScript (ES modules), Vitest, Node.js `fs/promises`, `readJSONLTolerant`, existing `getCodexDiscoverySessionRoots()`, `isCodexInjectedUserText()`, `chalk`, `mime-types`. + +## Global Constraints + +- All imports use `.js` extension (ES modules). +- Use `@/` alias for imports crossing the `src/` boundary; relative imports within the same directory are fine. +- No `require()`, no `__dirname`; use `import.meta.url` if a dirname is needed. +- Error handling: `try/catch` with `logger.error` or `logger.debug`; detection functions return `[]` on failure, never throw. +- No `console.log` in library code except for the user-facing chalk output already established in `claudeUploadsDetector.ts` (`📎 Detected N file(s)…`). +- Tests are Vitest; mock with `vi.mock()` + dynamic imports after setup. +- Commit messages: Conventional Commits (`feat(assistants): …`). + +--- + +## File Map + +| Path | Action | Purpose | +|---|---|---| +| `src/cli/commands/assistants/chat/uploads-types.ts` | **Create** | Shared `DetectedFile` interface + `readFilesFromPaths` + shared constants | +| `src/cli/commands/assistants/chat/claudeUploadsDetector.ts` | **Modify** | Re-export `DetectedFile`/`readFilesFromPaths` from `uploads-types.ts`; remove local definitions | +| `src/agents/plugins/codex/codex-message-types.ts` | **Modify** | Add `CodexResponseItemMessage`, `CodexContentBlock`, extend `CodexEventMsg` with `images`/`local_images` | +| `src/cli/commands/assistants/chat/codexUploadsDetector.ts` | **Create** | Codex-specific rollout discovery and attachment extraction | +| `src/cli/commands/assistants/chat/__tests__/codexUploadsDetector.test.ts` | **Create** | Unit tests for the new detector | +| `src/cli/commands/assistants/chat/index.ts` | **Modify** | Add `CODEMIE_AGENT`-aware dispatch; import from `uploads-types.ts` | +| `src/cli/commands/assistants/setup/generators/codex-skill-generator.ts` | **Modify** | Add note about automatic session-based file detection | + +--- + +### Task 1: Extract shared types to `uploads-types.ts` + +**Files:** +- Create: `src/cli/commands/assistants/chat/uploads-types.ts` +- Modify: `src/cli/commands/assistants/chat/claudeUploadsDetector.ts` (lines 9–22, 43–49, 247–348) +- Modify: `src/cli/commands/assistants/chat/index.ts` (line 24) + +**Interfaces:** +- Produces: `DetectedFile`, `readFilesFromPaths`, `MAX_FILE_SIZE_BYTES`, `BYTES_PER_KB`, `BYTES_PER_MB`, `DEFAULT_MEDIA_TYPE`, `logDetectedFiles` — all re-exported from `uploads-types.ts` +- Consumes: nothing new — pure refactor + +Test-first: **no** — this is a refactor; safety net is the existing `claudeUploadsDetector.test.ts` which tests `detectFileUploadsFromSession` and `readFilesFromPaths`. Run it after the rename to confirm no regression. + +- [ ] **Step 1: Create `uploads-types.ts`** + +```typescript +// src/cli/commands/assistants/chat/uploads-types.ts +import { existsSync, statSync, readFileSync } from 'fs'; +import { basename, resolve } from 'path'; +import chalk from 'chalk'; +import mime from 'mime-types'; +import { logger } from '@/utils/logger.js'; + +export const MAX_FILE_SIZE_MB = 100; +export const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; +export const BYTES_PER_KB = 1024; +export const BYTES_PER_MB = 1024 * 1024; +export const DEFAULT_MEDIA_TYPE = 'application/octet-stream'; + +export interface DetectedFile { + fileName: string; + data: string; + mediaType: string; + type: 'image' | 'document'; + sizeBytes: number; +} + +export function logDetectedFiles(files: DetectedFile[], quiet: boolean): void { + if (files.length === 0 || quiet) return; + console.log(chalk.cyan(`\n📎 Detected ${files.length} file(s) with content:`)); + files.forEach((file, index) => { + const sizeKB = Math.round(file.sizeBytes / BYTES_PER_KB); + console.log(chalk.dim(` ${index + 1}. ${file.fileName} (${file.mediaType}, ${sizeKB} KB)`)); + }); + console.log(''); +} + +function detectMimeType(filePath: string): string { + return mime.lookup(filePath) || DEFAULT_MEDIA_TYPE; +} + +function detectFileType(mimeType: string): 'image' | 'document' { + return mimeType.startsWith('image/') ? 'image' : 'document'; +} + +/** + * Read files from disk and convert to DetectedFile format. + * Agent-agnostic; used by both Claude and Codex detection paths. + */ +export async function readFilesFromPaths( + filePaths: string[], + options: { quiet?: boolean } = {} +): Promise { + const { quiet = false } = options; + const detectedFiles: DetectedFile[] = []; + if (filePaths.length === 0) return []; + + logger.debug('[uploads-types] Reading files from paths', { + fileCount: filePaths.length, + paths: filePaths, + }); + + for (const filePath of filePaths) { + try { + const absolutePath = resolve(filePath); + if (!existsSync(absolutePath)) { + logger.warn('[uploads-types] File does not exist', { filePath: absolutePath }); + if (!quiet) console.log(chalk.yellow(`⚠ File not found: ${filePath}`)); + continue; + } + const stats = statSync(absolutePath); + if (!stats.isFile()) { + logger.warn('[uploads-types] Path is not a file', { filePath: absolutePath }); + if (!quiet) console.log(chalk.yellow(`⚠ Not a file: ${filePath}`)); + continue; + } + if (stats.size > MAX_FILE_SIZE_BYTES) { + logger.warn('[uploads-types] File exceeds size limit', { + filePath: absolutePath, + sizeMB: (stats.size / BYTES_PER_MB).toFixed(2), + limit: MAX_FILE_SIZE_MB, + }); + if (!quiet) console.log(chalk.yellow(`⚠ File too large (>${MAX_FILE_SIZE_MB}MB): ${filePath}`)); + continue; + } + const fileBuffer = readFileSync(absolutePath); + const base64Data = fileBuffer.toString('base64'); + const fileName = basename(absolutePath); + const mimeType = detectMimeType(absolutePath); + const fileType = detectFileType(mimeType); + detectedFiles.push({ + fileName, + data: base64Data, + mediaType: mimeType, + type: fileType, + sizeBytes: stats.size, + }); + logger.debug('[uploads-types] Read file from disk', { + fileName, + mediaType: mimeType, + type: fileType, + sizeMB: (stats.size / BYTES_PER_MB).toFixed(2), + }); + } catch (error) { + logger.warn('[uploads-types] Failed to read file', { filePath, error }); + if (!quiet) console.log(chalk.yellow(`⚠ Failed to read file: ${filePath}`)); + } + } + + if (!quiet && detectedFiles.length > 0) { + console.log(chalk.cyan(`\n📎 Loaded ${detectedFiles.length} file(s) from disk:`)); + detectedFiles.forEach((file, index) => { + const sizeKB = Math.round(file.sizeBytes / BYTES_PER_KB); + console.log(chalk.dim(` ${index + 1}. ${file.fileName} (${file.mediaType}, ${sizeKB} KB)`)); + }); + console.log(''); + } + + logger.debug('[uploads-types] Files read from disk', { + requestedCount: filePaths.length, + successCount: detectedFiles.length, + }); + + return detectedFiles; +} +``` + +- [ ] **Step 2: Trim `claudeUploadsDetector.ts` — remove duplicated symbols** + +In `claudeUploadsDetector.ts`, delete: +- The `import { statSync }` from `fs` (if only used by `readFilesFromPaths`) +- `import mime from 'mime-types'` (if only used by `readFilesFromPaths`) +- Constants: `MAX_FILE_SIZE_MB`, `MAX_FILE_SIZE_BYTES`, `BYTES_PER_KB`, `BYTES_PER_MB`, `DEFAULT_MEDIA_TYPE` +- The `DetectedFile` interface (lines 43–49) +- Functions `logDetectedFiles`, `readFilesFromPaths`, `detectMimeType`, `detectFileType` (lines 216–348) + +Add at the top of `claudeUploadsDetector.ts` (after existing imports): +```typescript +import { + type DetectedFile, + readFilesFromPaths, + logDetectedFiles, + MAX_FILE_SIZE_BYTES, + BYTES_PER_MB, + DEFAULT_MEDIA_TYPE, +} from './uploads-types.js'; +``` + +Keep the `export type { DetectedFile }` and `export { readFilesFromPaths }` at module scope so existing callers (`index.ts`) see no import change yet. + +Actually add explicit re-exports at the bottom of the trimmed file: +```typescript +export type { DetectedFile }; +export { readFilesFromPaths }; +``` + +- [ ] **Step 3: Run existing Claude detector tests to verify no regression** + +```bash +npx vitest run src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/cli/commands/assistants/chat/uploads-types.ts \ + src/cli/commands/assistants/chat/claudeUploadsDetector.ts +git commit -m "refactor(assistants): extract DetectedFile and readFilesFromPaths to uploads-types.ts" +``` + +--- + +### Task 2: Add Codex message content types to `codex-message-types.ts` + +**Files:** +- Modify: `src/agents/plugins/codex/codex-message-types.ts` + +**Interfaces:** +- Produces: `CodexResponseItemMessage`, `CodexContentBlock`, and extended `CodexEventMsg` with `images?: string[]`, `local_images?: string[]` + +Test-first: **no** — type-only change; compiler verifies correctness when Task 3 uses these types. + +- [ ] **Step 1: Add new interfaces after `CodexResponseItem` (line 60)** + +Add after `CodexResponseItem`: +```typescript +/** + * The `message` sub-type of a `response_item` record — carries user/assistant + * content including images and documents. Not modelled in the base + * CodexResponseItem because that interface focuses on function-call shapes. + */ +export interface CodexResponseItemMessage { + type: 'message'; + role: 'user' | 'assistant'; + content: CodexContentBlock[]; +} + +/** Content block within a CodexResponseItemMessage.content array. */ +export interface CodexContentBlock { + type: 'input_text' | 'input_image' | 'input_file' | string; + text?: string; + /** Data URI: "data:;base64," — only on input_image blocks. */ + image_url?: string; +} +``` + +- [ ] **Step 2: Extend `CodexEventMsg` with user-message attachment fields** + +In `CodexEventMsg`, after `message?: string;`, add: +```typescript + /** Base64 image data URIs — always empty in practice; base64 lives in response_item. */ + images?: string[]; + /** Temp file paths on disk for attached images — basename is the filename fallback. */ + local_images?: string[]; + text_elements?: Array<{ + byte_range?: { start: number; end: number }; + placeholder?: string; + }>; +``` + +- [ ] **Step 3: Run typecheck to verify no breakage** + +```bash +npm run typecheck +``` + +Expected: exits 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/agents/plugins/codex/codex-message-types.ts +git commit -m "feat(codex): add CodexResponseItemMessage and CodexContentBlock types for attachment extraction" +``` + +--- + +### Task 3: Implement `codexUploadsDetector.ts` + +**Files:** +- Create: `src/cli/commands/assistants/chat/codexUploadsDetector.ts` +- Create: `src/cli/commands/assistants/chat/__tests__/codexUploadsDetector.test.ts` + +**Interfaces:** +- Consumes: `DetectedFile` from `./uploads-types.js`; `CodexRolloutRecord`, `CodexSessionMeta`, `CodexEventMsg`, `CodexResponseItemMessage`, `CodexContentBlock` from `@/agents/plugins/codex/codex-message-types.js`; `getCodexDiscoverySessionRoots` from `@/agents/plugins/codex/codex.paths.js`; `readJSONLTolerant` from `@/agents/core/session/utils/jsonl-reader.js`; `isCodexInjectedUserText` from `@/agents/plugins/codex/session/codex-user-prompt.js` +- Produces: `detectCodexFileUploads({ cwd: string, quiet?: boolean }): Promise` + +Test-first: **yes** — write the test with a synthetic rollout containing one `input_image` block; it must fail (function not found) before implementation. + +- [ ] **Step 1: Write the failing test** + +Create `src/cli/commands/assistants/chat/__tests__/codexUploadsDetector.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Must mock BEFORE importing the module under test (dynamic-import pattern required by Vitest) +vi.mock('@/agents/plugins/codex/codex.paths.js', () => ({ + getCodexDiscoverySessionRoots: vi.fn(), +})); +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readdir: vi.fn(), + stat: vi.fn(), + }; +}); +vi.mock('@/agents/core/session/utils/jsonl-reader.js', () => ({ + readJSONLTolerant: vi.fn(), +})); +vi.mock('@/utils/logger.js', () => ({ + logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +describe('detectCodexFileUploads', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns [] when no session directories found', async () => { + const { getCodexDiscoverySessionRoots } = await import('@/agents/plugins/codex/codex.paths.js'); + vi.mocked(getCodexDiscoverySessionRoots).mockReturnValue([]); + + const { detectCodexFileUploads } = await import('../codexUploadsDetector.js'); + const result = await detectCodexFileUploads({ cwd: '/project', quiet: true }); + expect(result).toEqual([]); + }); + + it('extracts input_image block from a matching rollout', async () => { + const { getCodexDiscoverySessionRoots } = await import('@/agents/plugins/codex/codex.paths.js'); + const { readdir, stat } = await import('fs/promises'); + const { readJSONLTolerant } = await import('@/agents/core/session/utils/jsonl-reader.js'); + + vi.mocked(getCodexDiscoverySessionRoots).mockReturnValue([ + { sessionsPath: '/fake/.codex/sessions', agentName: 'codex' }, + ]); + + // Fake directory scan: one day dir, one rollout file + vi.mocked(readdir).mockImplementation(async (p: unknown) => { + const path = p as string; + if (path === '/fake/.codex/sessions') return ['2026'] as unknown as import('fs').Dirent[]; + if (path.endsWith('/2026')) return ['08'] as unknown as import('fs').Dirent[]; + if (path.endsWith('/08')) return ['05'] as unknown as import('fs').Dirent[]; + if (path.endsWith('/05')) return ['rollout-2026-08-05T10:00:00.000Z-abc123.jsonl'] as unknown as import('fs').Dirent[]; + return [] as unknown as import('fs').Dirent[]; + }); + vi.mocked(stat).mockResolvedValue({ isDirectory: () => path.endsWith('/2026') || path.endsWith('/08') || path.endsWith('/05'), mtime: new Date(2026, 7, 5, 10, 0, 0) } as import('fs').Stats); + + // Rollout content: session_meta with matching CWD + user response_item with input_image + event_msg + const fakeBase64 = Buffer.from('PNG_FAKE_DATA').toString('base64'); + vi.mocked(readJSONLTolerant).mockResolvedValue([ + { + type: 'session_meta', + payload: { id: 'abc123', timestamp: '2026-08-05T10:00:00Z', cwd: '/project' }, + }, + { + type: 'event_msg', + payload: { + type: 'user_message', + message: '$codemie-jira-assistant [Image #1]', + images: [], + local_images: ['/var/tmp/codex-clipboard-abc.png'], + turn_id: 'turn-1', + }, + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: '' }, + { type: 'input_image', image_url: `data:image/png;base64,${fakeBase64}` }, + { type: 'input_text', text: '' }, + { type: 'input_text', text: '$codemie-jira-assistant [Image #1]' }, + ], + }, + }, + ]); + + const { detectCodexFileUploads } = await import('../codexUploadsDetector.js'); + const result = await detectCodexFileUploads({ cwd: '/project', quiet: true }); + + expect(result).toHaveLength(1); + expect(result[0].fileName).toBe('codex-clipboard-abc.png'); + expect(result[0].mediaType).toBe('image/png'); + expect(result[0].type).toBe('image'); + expect(result[0].data).toBe(fakeBase64); + }); + + it('returns [] when rollout CWD does not match', async () => { + const { getCodexDiscoverySessionRoots } = await import('@/agents/plugins/codex/codex.paths.js'); + const { readdir, stat } = await import('fs/promises'); + const { readJSONLTolerant } = await import('@/agents/core/session/utils/jsonl-reader.js'); + + vi.mocked(getCodexDiscoverySessionRoots).mockReturnValue([ + { sessionsPath: '/fake/.codex/sessions', agentName: 'codex' }, + ]); + vi.mocked(readdir).mockImplementation(async (p: unknown) => { + const path = p as string; + if (path === '/fake/.codex/sessions') return ['2026'] as unknown as import('fs').Dirent[]; + if (path.endsWith('/2026')) return ['08'] as unknown as import('fs').Dirent[]; + if (path.endsWith('/08')) return ['05'] as unknown as import('fs').Dirent[]; + if (path.endsWith('/05')) return ['rollout-2026-08-05T10:00:00.000Z-abc123.jsonl'] as unknown as import('fs').Dirent[]; + return [] as unknown as import('fs').Dirent[]; + }); + vi.mocked(stat).mockResolvedValue({ isDirectory: () => false, mtime: new Date() } as import('fs').Stats); + + vi.mocked(readJSONLTolerant).mockResolvedValue([ + { + type: 'session_meta', + payload: { id: 'abc123', timestamp: '2026-08-05T10:00:00Z', cwd: '/other/project' }, + }, + ]); + + const { detectCodexFileUploads } = await import('../codexUploadsDetector.js'); + const result = await detectCodexFileUploads({ cwd: '/project', quiet: true }); + expect(result).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails (RED)** + +```bash +npx vitest run src/cli/commands/assistants/chat/__tests__/codexUploadsDetector.test.ts +``` + +Expected: `Cannot find module '../codexUploadsDetector.js'` or similar import error. + +- [ ] **Step 3: Implement `codexUploadsDetector.ts`** + +Create `src/cli/commands/assistants/chat/codexUploadsDetector.ts`: + +```typescript +/** + * Codex-specific file attachment detector. + * + * Discovers the active Codex rollout file by scanning the Codex sessions + * directories for a rollout whose session_meta.cwd matches process.cwd(), + * then extracts input_image / input_file blocks from the most recent user + * response_item record. + * + * Does NOT use CodexSessionAdapter (which requires AgentMetadata) — rollout + * discovery is implemented directly via getCodexDiscoverySessionRoots() + + * readJSONLTolerant to keep the CLI command layer dependency-light. + */ + +import { realpath as fsRealpath, readdir, stat } from 'fs/promises'; +import { basename, join } from 'path'; +import chalk from 'chalk'; +import { logger } from '@/utils/logger.js'; +import { readJSONLTolerant } from '@/agents/core/session/utils/jsonl-reader.js'; +import { getCodexDiscoverySessionRoots } from '@/agents/plugins/codex/codex.paths.js'; +import { isCodexInjectedUserText } from '@/agents/plugins/codex/session/codex-user-prompt.js'; +import type { + CodexRolloutRecord, + CodexSessionMeta, + CodexEventMsg, + CodexResponseItemMessage, + CodexContentBlock, +} from '@/agents/plugins/codex/codex-message-types.js'; +import { + type DetectedFile, + logDetectedFiles, + MAX_FILE_SIZE_BYTES, + MAX_FILE_SIZE_MB, + BYTES_PER_MB, + DEFAULT_MEDIA_TYPE, +} from './uploads-types.js'; + +const LOG_PREFIX = '[codexUploadsDetector]'; +const ROLLOUT_FILE_PATTERN = /^rollout-.*\.jsonl$/; +const IMAGE_WRAPPER_PATTERN = //; +const MAX_ROLLOUT_AGE_DAYS = 1; +const MS_PER_DAY = 86_400_000; + +export interface DetectCodexFileUploadsOptions { + cwd: string; + quiet?: boolean; +} + +async function safeRealpath(p: string): Promise { + try { + return await fsRealpath(p); + } catch { + return p; + } +} + +/** + * Scan Codex session day-directory for rollout files, returning descriptors + * sorted newest-first. + */ +async function discoverRolloutFiles( + sessionsPath: string, + cutoffMs: number +): Promise> { + const results: Array<{ filePath: string; mtime: number }> = []; + + let yearDirs: string[]; + try { + yearDirs = await readdir(sessionsPath); + } catch { + return results; + } + + for (const yearDir of yearDirs) { + const yearPath = join(sessionsPath, yearDir); + let monthDirs: string[]; + try { + monthDirs = await readdir(yearPath); + } catch { continue; } + + for (const monthDir of monthDirs) { + const monthPath = join(yearPath, monthDir); + let dayDirs: string[]; + try { + dayDirs = await readdir(monthPath); + } catch { continue; } + + for (const dayDir of dayDirs) { + const dayPath = join(monthPath, dayDir); + let files: string[]; + try { + files = await readdir(dayPath); + } catch { continue; } + + for (const file of files) { + if (!ROLLOUT_FILE_PATTERN.test(file)) continue; + const filePath = join(dayPath, file); + try { + const s = await stat(filePath); + if (s.mtime.getTime() >= cutoffMs) { + results.push({ filePath, mtime: s.mtime.getTime() }); + } + } catch { /* skip unreadable */ } + } + } + } + } + + results.sort((a, b) => b.mtime - a.mtime); + return results; +} + +/** + * Find the rollout file whose session_meta.cwd resolves to cwdReal. + * Returns the file path of the newest matching rollout, or null. + */ +async function findMatchingRollout(cwdReal: string): Promise { + const roots = getCodexDiscoverySessionRoots(); + if (!roots.length) { + logger.debug(`${LOG_PREFIX} No Codex session directories found`); + return null; + } + + const cutoffMs = Date.now() - MAX_ROLLOUT_AGE_DAYS * MS_PER_DAY; + const allCandidates: Array<{ filePath: string; mtime: number }> = []; + + for (const root of roots) { + const candidates = await discoverRolloutFiles(root.sessionsPath, cutoffMs); + allCandidates.push(...candidates); + } + + allCandidates.sort((a, b) => b.mtime - a.mtime); + + for (const { filePath } of allCandidates) { + const records = await readJSONLTolerant(filePath, LOG_PREFIX); + const metaRecord = records.find((r) => r.type === 'session_meta'); + if (!metaRecord) continue; + + const sessionMeta = metaRecord.payload as CodexSessionMeta; + const metaReal = await safeRealpath(sessionMeta.cwd); + + if (metaReal === cwdReal) { + logger.debug(`${LOG_PREFIX} Matched rollout`, { filePath, cwd: sessionMeta.cwd }); + return filePath; + } + } + + return null; +} + +/** + * Process a single input_image content block into a DetectedFile. + * Returns null when the block is invalid or too large. + */ +function processImageBlock( + block: CodexContentBlock, + fileName: string +): DetectedFile | null { + if (!block.image_url) { + logger.warn(`${LOG_PREFIX} input_image block missing image_url`, { fileName }); + return null; + } + + const commaIdx = block.image_url.indexOf(','); + if (commaIdx === -1) { + logger.warn(`${LOG_PREFIX} Malformed data URI in input_image block`, { fileName }); + return null; + } + + const prefix = block.image_url.slice(0, commaIdx); + const base64Data = block.image_url.slice(commaIdx + 1); + const mimeMatch = /data:([^;]+);base64/.exec(prefix); + const mediaType = mimeMatch?.[1] ?? DEFAULT_MEDIA_TYPE; + + try { + const fileSize = Buffer.from(base64Data, 'base64').length; + if (fileSize > MAX_FILE_SIZE_BYTES) { + logger.warn(`${LOG_PREFIX} File exceeds size limit, skipping`, { + fileName, + sizeMB: (fileSize / BYTES_PER_MB).toFixed(2), + limit: MAX_FILE_SIZE_MB, + }); + return null; + } + + return { + fileName, + data: base64Data, + mediaType, + type: 'image', + sizeBytes: fileSize, + }; + } catch (error) { + logger.warn(`${LOG_PREFIX} Invalid base64 data`, { fileName, error }); + return null; + } +} + +/** + * Extract attached files from a Codex rollout record array. + * + * Algorithm: + * 1. Scan backward for the last event_msg with type user_message that is + * not injected context — captures local_images for filename fallback. + * 2. Scan backward for the last response_item with role user that has + * input_image/input_file blocks and is not purely injected text. + * 3. Walk the content array; for each input_image block, extract filename + * from the preceding text wrapper (or + * local_images[i] as fallback) and parse the data URI. + */ +function extractAttachmentsFromRecords(records: CodexRolloutRecord[]): DetectedFile[] { + let targetResponseItem: CodexResponseItemMessage | null = null; + let targetEventMsg: (CodexEventMsg & { local_images?: string[] }) | null = null; + + // Single backward pass: find both anchor records + for (let i = records.length - 1; i >= 0; i--) { + const record = records[i]; + + if (!targetResponseItem && record.type === 'response_item') { + const payload = record.payload as unknown as CodexResponseItemMessage; + if ( + payload.type === 'message' && + payload.role === 'user' && + Array.isArray(payload.content) && + payload.content.some((b) => b.type === 'input_image' || b.type === 'input_file') + ) { + // Confirm the user text is not entirely injected + const userText = payload.content + .filter((b): b is CodexContentBlock & { text: string } => + b.type === 'input_text' && typeof b.text === 'string' + ) + .map((b) => b.text) + .join(' ') + .trim(); + if (!userText || !isCodexInjectedUserText(userText)) { + targetResponseItem = payload; + } + } + } + + if (!targetEventMsg && record.type === 'event_msg') { + const payload = record.payload as CodexEventMsg & { local_images?: string[] }; + if ( + payload.type === 'user_message' && + typeof payload.message === 'string' && + !isCodexInjectedUserText(payload.message) + ) { + targetEventMsg = payload; + } + } + + if (targetResponseItem && targetEventMsg) break; + } + + if (!targetResponseItem) { + logger.debug(`${LOG_PREFIX} No user response_item with attachments found`); + return []; + } + + const detectedFiles: DetectedFile[] = []; + const content = targetResponseItem.content; + let localImageIndex = 0; + + for (let i = 0; i < content.length; i++) { + const block = content[i]; + if (block.type !== 'input_image' && block.type !== 'input_file') continue; + + // Filename: check preceding wrapper + let fileName: string | undefined; + if (i > 0) { + const prev = content[i - 1]; + if (prev.type === 'input_text' && typeof prev.text === 'string') { + const match = IMAGE_WRAPPER_PATTERN.exec(prev.text); + if (match) fileName = basename(match[1]); + } + } + // Fallback to local_images basename + if (!fileName) { + const localPath = targetEventMsg?.local_images?.[localImageIndex]; + if (localPath) fileName = basename(localPath); + } + fileName = fileName || `attachment_${localImageIndex}`; + localImageIndex++; + + if (block.type === 'input_image') { + const detectedFile = processImageBlock(block, fileName); + if (detectedFile) detectedFiles.push(detectedFile); + } + // input_file handling can be added here in future iterations + } + + logger.debug(`${LOG_PREFIX} Extracted attachments`, { + count: detectedFiles.length, + }); + + return detectedFiles; +} + +/** + * Detect file uploads from the active Codex rollout. + * + * Finds the most recent rollout matching the given CWD, then extracts any + * files attached in the most recent user turn. + * + * @param options.cwd Project directory (typically process.cwd()) + * @param options.quiet Suppress console output when true + */ +export async function detectCodexFileUploads( + options: DetectCodexFileUploadsOptions +): Promise { + const { cwd, quiet = false } = options; + + logger.debug(`${LOG_PREFIX} Detecting file uploads from Codex rollout`, { cwd }); + + try { + const cwdReal = await safeRealpath(cwd); + const rolloutPath = await findMatchingRollout(cwdReal); + + if (!rolloutPath) { + logger.debug(`${LOG_PREFIX} No matching rollout found for cwd`, { cwdReal }); + return []; + } + + const records = await readJSONLTolerant(rolloutPath, LOG_PREFIX); + const detectedFiles = extractAttachmentsFromRecords(records); + + logDetectedFiles(detectedFiles, quiet); + + logger.debug(`${LOG_PREFIX} Detection complete`, { + filesDetected: detectedFiles.length, + }); + + return detectedFiles; + } catch (error) { + logger.debug(`${LOG_PREFIX} Failed to detect file uploads`, { error }); + return []; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass (GREEN)** + +```bash +npx vitest run src/cli/commands/assistants/chat/__tests__/codexUploadsDetector.test.ts +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: Typecheck** + +```bash +npm run typecheck +``` + +Expected: exits 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/cli/commands/assistants/chat/codexUploadsDetector.ts \ + src/cli/commands/assistants/chat/__tests__/codexUploadsDetector.test.ts +git commit -m "feat(assistants): implement codexUploadsDetector for Codex rollout attachment extraction" +``` + +--- + +### Task 4: Wire agent-aware dispatch in `chat/index.ts` + +**Files:** +- Modify: `src/cli/commands/assistants/chat/index.ts` + +**Interfaces:** +- Consumes: `detectCodexFileUploads` from `./codexUploadsDetector.js`; `DetectedFile`, `readFilesFromPaths` from `./uploads-types.js` +- No change to `detectFileUploadsFromSession` import from `./claudeUploadsDetector.js` + +Test-first: **no** — simple conditional branch; verified by typecheck and the integration path visible in existing architecture. + +- [ ] **Step 1: Update the import on line 24** + +Replace: +```typescript +import { detectFileUploadsFromSession, readFilesFromPaths, type DetectedFile } from './claudeUploadsDetector.js'; +``` +With: +```typescript +import { detectFileUploadsFromSession } from './claudeUploadsDetector.js'; +import { detectCodexFileUploads } from './codexUploadsDetector.js'; +import { readFilesFromPaths, type DetectedFile } from './uploads-types.js'; +``` + +- [ ] **Step 2: Replace the file detection block (lines 98–104)** + +Replace: +```typescript + // 1. Detect files from the Claude session (always use CODEMIE_SESSION_ID, not --conversation-id). + // --conversation-id identifies the assistant chat thread; CODEMIE_SESSION_ID identifies the + // Claude session whose JSONL contains the uploaded file blobs. + const claudeSessionId = process.env.CODEMIE_SESSION_ID; + if (claudeSessionId) { + detectedFiles = await detectFileUploadsFromSession(claudeSessionId, { quiet: false }); + } +``` +With: +```typescript + // 1. Detect files from the agent session. + // CODEMIE_AGENT selects the detection strategy: + // - 'codex': scan Codex rollout JSONL via CWD match (hooks non-functional in Codex) + // - default: read Claude session JSONL via CODEMIE_SESSION_ID → correlation → agentSessionFile + const agentName = process.env.CODEMIE_AGENT; + if (agentName === 'codex') { + detectedFiles = await detectCodexFileUploads({ cwd: process.cwd(), quiet: false }); + } else { + const claudeSessionId = process.env.CODEMIE_SESSION_ID; + if (claudeSessionId) { + detectedFiles = await detectFileUploadsFromSession(claudeSessionId, { quiet: false }); + } + } +``` + +- [ ] **Step 3: Typecheck** + +```bash +npm run typecheck +``` + +Expected: exits 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/cli/commands/assistants/chat/index.ts +git commit -m "feat(assistants): add CODEMIE_AGENT-aware file detection dispatch for Codex" +``` + +--- + +### Task 5: Update `codex-skill-generator.ts` — add file attachment note + +**Files:** +- Modify: `src/cli/commands/assistants/setup/generators/codex-skill-generator.ts` + +**Interfaces:** +- No interface changes — template update only + +Test-first: **no** — string template; no logic. + +- [ ] **Step 1: Update the skill template in `createSkillContent` to mention automatic detection** + +In `createSkillContent` (line 30), update the instructions section. After the existing step 4 (`After any write, re-fetch…`), add a step 5 about file attachments: + +Locate the current text at the end of `dedent\`` block (after step 4): +```typescript + Run CodeMie assistant chat with the user's message: +``` + +Before that line add step 5: +```typescript + 5. **File attachments are automatically detected** — if the user attaches an image or document when invoking this skill, CodeMie will detect it from the Codex session rollout automatically. You do not need to pass \`CODEMIE_SESSION_ID\` or any attachment flag explicitly; the \`codemie assistants chat\` command handles it. Use \`--file\` only when you want to attach a file from the filesystem that was NOT dragged into the Codex session. + +``` + +- [ ] **Step 2: Typecheck and lint** + +```bash +npm run typecheck && npm run lint +``` + +Expected: exits 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/cli/commands/assistants/setup/generators/codex-skill-generator.ts +git commit -m "docs(assistants): note automatic file detection in Codex skill template" +``` + +--- + +## Self-Review Checklist + +**Spec coverage:** + +| AC | Task | +|---|---| +| Investigation completed on how Codex stores file attachments | Research pre-completed (format analysis doc); documented in `codexUploadsDetector.ts` header | +| Extend file attachments to support Codex | Task 3 + Task 4 | +| User can invoke via `/slug` or `@slug` with a file attached | Task 4 dispatch wires up; skill template updated in Task 5 | +| Message and file attachment sent to CodeMie correctly | `uploadFilesToCodeMie` and `sendMessageWithHistory` unchanged; `DetectedFile` contract preserved | +| Attachment support works for Codex assistants from `codemie setup assistants` | Task 5 skill generator update | +| No regression for Claude | Task 1 preserves Claude path via `else` branch; existing tests remain | +| No regression for invocations without attachments | `detectCodexFileUploads` returns `[]` when no rollout found | +| Codex-specific constraints documented | `codexUploadsDetector.ts` module docstring; type extensions in `codex-message-types.ts` | + +**Placeholder scan:** No TBDs, no "similar to" references, all code blocks complete. + +**Type consistency:** +- `DetectedFile` defined in `uploads-types.ts`, re-exported from `claudeUploadsDetector.ts`, imported in `index.ts` from `uploads-types.ts` directly. +- `detectCodexFileUploads` signature: `(options: DetectCodexFileUploadsOptions): Promise` — matches the call site in `index.ts`. +- `CodexResponseItemMessage.content: CodexContentBlock[]` — used in detector; consistent with type definitions in Task 2. diff --git a/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/technical-analysis.md b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/technical-analysis.md new file mode 100644 index 000000000..45f22ffc3 --- /dev/null +++ b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/technical-analysis.md @@ -0,0 +1,270 @@ +# Technical Research + +**Task**: codex uploads detector assistants chat session correlation +**Generated**: 2026-08-05T00:00:00Z +**Research path**: codegraph + filesystem + +--- + +## 1. Original Context + +**Ticket:** EPMCDME-13885 — CodeMie CLI (Codex): implement file attachments support for CodeMie assistants + +**Description:** +Extend the current file attachments implementation used for CodeMie assistant invocation so that it also supports Codex. Users can register assistants for Codex via `codemie setup assistants` and invoke them via `/slug` or `@slug`. This task ensures that when a user invokes a configured CodeMie assistant from Codex with a file, the attachment is correctly collected, transferred to CodeMie, and handled by the assistant. This improves feature parity across supported CLI agents. + +**Acceptance criteria:** +- Investigation is completed to determine how Codex stores and exposes file attachments for assistant invocation flows. +- The current file attachments implementation (currently works for Claude) is extended to support Codex as well. +- A user can invoke a configured CodeMie assistant from Codex via `/slug` or `@slug` with a file attached. +- The message and file attachment are sent to CodeMie and handled correctly by the assistant. +- Attachment support works for Codex assistant flows created via `codemie setup assistants`. +- No regression is introduced for existing attachment support in Claude. +- No regression is introduced for assistant invocation without attachments. + +--- + +## 2. Codebase Findings + +### Existing Implementations + +**Claude upload detector (reference implementation):** +- `src/cli/commands/assistants/chat/claudeUploadsDetector.ts` — Claude-exclusive. Entry point: `detectFileUploadsFromSession(sessionId, options)`. Internal flow: + 1. `readSessionMetadata(sessionId)` → reads `~/.codemie/sessions/{id}.json` → `Session` + 2. `extractAgentSessionFile(session)` → reads `session.correlation.agentSessionFile` (requires `status === 'matched'`) + 3. `readJSONL(agentSessionFile)` → parses the Claude JSONL transcript + 4. `extractFileContentFromMessages(messages)` → finds most recent non-meta user message, captures its `promptId`, collects `isMeta` messages with the same `promptId`, extracts `image`/`document` content items where `source.type === 'base64'` and `source.data` holds the raw base64 string + 5. Returns `DetectedFile[]` + +- `src/cli/commands/assistants/chat/claudeUploadsDetector.ts` also exports: + - `readFilesFromPaths(filePaths, options)` → reads files from `--file` CLI paths; shared by both Claude and Codex code paths + - `DetectedFile` interface: `{ fileName, data, mediaType, type: 'image' | 'document', sizeBytes }` + +**Chat orchestration (integration point):** +- `src/cli/commands/assistants/chat/index.ts` — key section (lines 100–110): + ```typescript + const claudeSessionId = process.env.CODEMIE_SESSION_ID; + if (claudeSessionId) { + detectedFiles = await detectFileUploadsFromSession(claudeSessionId, { quiet: false }); + } + if (options.file && options.file.length > 0) { + const filesFromPaths = await readFilesFromPaths(options.file, { quiet: false }); + detectedFiles = [...detectedFiles, ...filesFromPaths]; + } + ``` + - This code does NOT branch on agent type. For Codex the same `CODEMIE_SESSION_ID` env var is checked, but `detectFileUploadsFromSession` will silently return `[]` because `session.correlation.agentSessionFile` is never populated for Codex (hooks are non-functional). + - `uploadFilesToCodeMie(client, files)` → calls `client.files.bulkUpload(FileToUpload[])` → returns `string[]` (file URLs) + - `sendMessageWithHistory` passes `file_names: fileUrls` to `client.assistants.chat()` + +**Codex plugin:** +- `src/agents/plugins/codex/codex.plugin.ts` — `onSessionStart(sessionId, env)`: + - Calls `processEvent(SessionStart)` → creates `~/.codemie/sessions/{id}.json` with `correlation: { status: 'pending', retryCount: 0 }` + - Calls `startCodexIncrementalSync({ sessionId, startedAt, cwd, ... })` + - Does NOT populate `session.correlation.agentSessionFile` — this is never set for Codex sessions + - The comment block at lines 27–35 explicitly documents that Codex hooks are non-functional + +- `src/agents/plugins/codex/codex.incremental-sync.ts` — timer tick pattern for rollout discovery: + ```typescript + const adapter = new CodexSessionAdapter(options.metadata); + const sessions = await adapter.discoverSessions({ maxAgeDays: 1, limit: 10 }); + for (const descriptor of sessions) { + if (descriptor.createdAt < options.startedAt - STARTED_AT_GRACE_MS) continue; + const parsed = await adapter.parseSessionFile(descriptor.filePath, options.sessionId); + const projectPath = parsed.metadata?.projectPath; + const projectReal = await safeRealpath(projectPath); + if (projectReal !== cwdReal) continue; + // process this rollout + } + ``` + This is the canonical pattern for finding the active rollout matching the current CWD. The new `codexUploadsDetector.ts` must use the same pattern. + +**Codex session adapter:** +- `src/agents/plugins/codex/codex.session.ts` — `CodexSessionAdapter`: + - `discoverSessions({ maxAgeDays, limit })` → scans `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` and `~/.codex/codemie/home/sessions/YYYY/MM/DD/rollout-*.jsonl`; returns `SessionDescriptor[]` sorted newest-first + - `parseSessionFile(filePath, sessionId)` → reads rollout JSONL, extracts `session_meta`/`turn_context`; returns parsed metadata including `metadata.projectPath` (the CWD when Codex started) + +**Codex message types:** +- `src/agents/plugins/codex/codex-message-types.ts` — defines `CodexLine` discriminated union; contains `response_item` and `event_msg` record shapes + +**Codex user prompt parser:** +- `src/agents/plugins/codex/session/codex-user-prompt.ts` — `firstCodexUserText()` and `isCodexInjectedUserText()` demonstrate the rollout record shapes: + - `event_msg { payload.type === 'user_message', payload.message }` — text-only metadata + - `response_item { payload.type === 'message', payload.role === 'user', payload.content: ContentBlock[] }` — actual content with `input_text` / `input_image` / `input_file` blocks + +**Session types:** +- `src/agents/core/session/types.ts` — `Session.correlation: CorrelationResult`: + ```typescript + interface CorrelationResult { + status: CorrelationStatus; // 'pending' | 'matched' | 'failed' + agentSessionFile?: string; // Path to matched agent JSONL/rollout file + agentSessionId?: string; + detectedAt?: number; + retryCount: number; + } + ``` + For Codex, `status` is always `'pending'` and `agentSessionFile` is never set. + +**Utilities reusable by new detector:** +- `src/agents/core/session/utils/jsonl-reader.ts` — `readJSONL()` and `readJSONLTolerant()` — shared JSONL reader +- `src/cli/commands/assistants/chat/claudeUploadsDetector.ts` — `readFilesFromPaths()` export is already agent-agnostic and reusable + +--- + +### Architecture and Layers Affected + +| Layer | Component | Change required | +|---|---|---| +| CLI / Command | `src/cli/commands/assistants/chat/index.ts` | Add agent-aware dispatch: check `CODEMIE_AGENT` env var, call Codex detector when agent is `codex` | +| CLI / Detector | `src/cli/commands/assistants/chat/codexUploadsDetector.ts` | **New file** — Codex-specific rollout parsing analogous to `claudeUploadsDetector.ts` | +| Agent Plugin | `src/agents/plugins/codex/codex.plugin.ts` | `onSessionStart`: optionally store rollout discovery criteria; see risk notes | +| Core Session | `src/agents/core/session/types.ts` | No changes required; `CorrelationResult.agentSessionFile` shape is already sufficient | + +--- + +### Integration Points + +**Agent context detection in `chat/index.ts`:** +- `process.env.CODEMIE_AGENT` is set by `BaseAgentAdapter` from `metadata.name` before the child process runs. Inside a Codex skill invocation, `CODEMIE_AGENT === 'codex'`. +- The dispatch branch should be: + ```typescript + const agentName = process.env.CODEMIE_AGENT; + if (agentName === 'codex') { + detectedFiles = await detectCodexFileUploads({ cwd: process.cwd(), quiet: false }); + } else if (process.env.CODEMIE_SESSION_ID) { + detectedFiles = await detectFileUploadsFromSession(process.env.CODEMIE_SESSION_ID, { quiet: false }); + } + ``` + +**Rollout discovery in `codexUploadsDetector.ts`:** +- Use `CodexSessionAdapter.discoverSessions({ maxAgeDays: 1, limit: 10 })` + CWD realpath match (identical to incremental-sync tick pattern). +- No dependency on `session.correlation.agentSessionFile` — bypasses the broken hook correlation entirely. +- Input: current `process.cwd()` (the project directory where Codex was launched). +- Discovery must be tolerant of concurrent write: the rollout is being appended to as Codex runs. Use `readJSONLTolerant()`. + +**Turn identification in the rollout:** +- Find the most recent `turn_id` that has a `response_item` record with `payload.role === 'user'` containing `input_image` or `input_file` blocks. +- Image data: `block.image_url` is a data URI `"data:;base64,"` — extract MIME and base64 by splitting on `,`. +- Filename: extracted from the `` wrapper text block that precedes the `input_image` block in the same `content` array; fall back to `event_msg.local_images[i]` for the same `turn_id`. + +**SDK upload chain (unchanged):** +- `DetectedFile` returned by the new detector must match the existing interface exactly (same fields: `fileName`, `data`, `mediaType`, `type`, `sizeBytes`) so `uploadFilesToCodeMie()` and `sendMessageWithHistory()` require no changes. + +--- + +### Patterns and Conventions + +- Detector files in `src/cli/commands/assistants/chat/` follow the pattern: named `UploadsDetector.ts`, export a primary `detect*` async function returning `Promise`, use `logger.debug` for diagnostics and `chalk` for console output. +- All JSONL reading goes through `readJSONL` / `readJSONLTolerant` from `src/agents/core/session/utils/jsonl-reader.ts`. +- Realpath normalization via `fsRealpath` + `safeRealpath` fallback is required for CWD matching (macOS symlinks: `/Users/foo` ↔ `/private/Users/foo`). +- The `DetectedFile` interface is defined in `claudeUploadsDetector.ts` and imported into `index.ts`; the new Codex detector should import and reuse `DetectedFile` rather than redefining it. +- Agent plugin lifecycle methods (`onSessionStart`, `onSessionEnd`) use fire-and-forget error handling (`try/catch` with `logger.error`, never throwing). +- Constants (type strings, status codes) are extracted into `const` objects at module top level. + +--- + +## 3. Documentation Findings + +### Guides and Architecture Docs + +- `.ai-run/guides/architecture/architecture.md` — plugin-based 5-layer architecture; CLI layer dispatches to Provider/Plugin layer via Registry +- `.ai-run/guides/integration/external-integrations.md` — provider plugin patterns, SSO, agent adapters +- `.ai-run/guides/integration/exposed-api.md` — CLI surface, plugin contracts +- `docs/superpowers/plans/2026-05-09-codex-hooks-incremental-sync.md` — records the investigation confirming Codex hooks are non-functional; establishes the incremental-sync timer as the canonical workaround + +### Architectural Decisions + +- **ADR (inline, `codex.plugin.ts` lines 27–35):** Codex hooks advertised in 0.129.0 were confirmed non-firing on `codex exec`; timer-based incremental sync chosen as the workaround. +- **Decision (inline, `codex.plugin.ts` `beforeRun`):** CodeMie-managed Codex runs use `CODEX_HOME=~/.codex/codemie/home` to isolate state from native Codex. +- **Decision (inline, `codex.plugin.ts` `enrichArgs`):** Custom `model_providers.codemie` provider used to bypass `~/.codex/auth.json` precedence for OPENAI_API_KEY. +- **Decision (`claudeUploadsDetector.ts` comment):** Session detection always uses `CODEMIE_SESSION_ID` (not `--conversation-id`) because the two identify different things: the agent session vs the assistant chat thread. + +### Derived Conventions + +- New detector must be in `src/cli/commands/assistants/chat/` alongside `claudeUploadsDetector.ts`. +- Rollout discovery must use `CodexSessionAdapter` (not raw `fs.glob`) to inherit the multi-root scan logic and `SessionDescriptor` sorting. +- The `DetectedFile` interface defined in `claudeUploadsDetector.ts` is the shared contract; do not duplicate it. +- Agent-discriminating logic in `index.ts` must default to the Claude path so existing behavior is preserved when `CODEMIE_AGENT` is unset or `'claude'`. + +--- + +## 4. Testing Landscape + +### Existing Coverage + +- `src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts` — covers `detectFileUploadsFromSession` (3 call sites); uses fixture JSONL files; tests `isMeta`+`promptId` grouping logic. **No Codex analog exists yet.** +- `src/agents/plugins/codex/__tests__/codex.incremental-sync.test.ts` — covers `startCodexIncrementalSync` / `stopCodexIncrementalSync` with a fake adapter; does not test rollout-to-file extraction. +- `src/agents/plugins/codex/__tests__/codex.paths.test.ts` — covers `getCodexSessionDayPath`. +- `src/cli/commands/analytics/cost/__tests__/usage-readers.test.ts` — covers `extractCodexUsageRecords` using fixture rollout files at `tests/integration/session/fixtures/codex/`. + +### Testing Framework and Patterns + +- **Framework**: Vitest +- **Fixtures**: Codex rollout fixture files exist at `tests/integration/session/fixtures/codex/`. The new detector tests should add a fixture rollout JSONL containing `response_item` records with `input_image` blocks. +- **Mocking pattern**: dynamic imports after `vi.mock()` setup (per `.ai-run/guides/testing/testing-patterns.md`). +- **Claude detector test pattern**: mock `fs.existsSync`, `fs.readFileSync`, and the JSONL reader; supply synthetic `ClaudeMessage[]` arrays. The same approach applies to Codex tests using synthetic `CodexLine[]` arrays. + +### Coverage Gaps + +- `codexUploadsDetector.ts` — entire new module; zero coverage until tests are written +- `codex.plugin.ts` `onSessionStart` — no test for correlation store update if it is added +- `chat/index.ts` agent-dispatch branch — no test for `CODEMIE_AGENT === 'codex'` path + +--- + +## 5. Configuration and Environment + +### Environment Variables + +| Variable | Purpose | +|---|---| +| `CODEMIE_SESSION_ID` | CodeMie session ID set by hooks; used by Claude detector path; Codex does not set this reliably via hooks but `onSessionStart` creates the session file with this ID | +| `CODEMIE_AGENT` | Agent name string (`'codex'`, `'claude'`, etc.); set by `BaseAgentAdapter` from `metadata.name`; used by `chat/index.ts` to choose detector | +| `CODEX_HOME` | Codex home dir override; set to `~/.codex/codemie/home` by `beforeRun` for CodeMie-managed runs | +| `CODEMIE_CODEX_SYNC_ENABLED` | Set to `'false'` to disable incremental sync | +| `CODEMIE_CODEX_SYNC_INTERVAL_MS` | Sync interval override (default 30 000 ms) | + +### Configuration Files + +- `~/.codemie/sessions/{id}.json` — `Session` object; `correlation.agentSessionFile` is populated for Claude, not for Codex +- `~/.codex/sessions/YYYY/MM/DD/rollout-{ISO8601}-{uuid}.jsonl` — Codex rollout (native CODEX_HOME) +- `~/.codex/codemie/home/sessions/YYYY/MM/DD/rollout-*.jsonl` — Codex rollout (CodeMie-managed CODEX_HOME) +- `~/.codex/skills/{slug}/SKILL.md` — generated by `codex-skill-generator.ts`; current template instructs agent to use `--file`; no session-based detection mentioned + +### Feature Flags and Deployment Concerns + +- No feature flags gate this functionality; the agent-dispatch branch in `index.ts` is the only toggle (presence of `CODEMIE_AGENT`). +- The generated Codex skill template in `src/cli/commands/assistants/setup/generators/codex-skill-generator.ts` currently tells the skill "do NOT use `CODEMIE_SESSION_ID` as a fallback" — this note may become stale or misleading once session-based detection is in place and should be reviewed. + +--- + +## 6. Risk Indicators + +- **No `session.correlation.agentSessionFile` for Codex** — `onSessionStart` creates the session file with `status: 'pending'`, and `agentSessionFile` is never set because hooks do not fire. The new detector must bypass `extractAgentSessionFile()` entirely and discover the rollout directly via `CodexSessionAdapter.discoverSessions()` + CWD matching, identical to the incremental-sync tick pattern. + +- **Rollout timing race** — at the moment `codemie assistants chat` is invoked from a Codex skill, the rollout file is still being written (Codex is running). The detector must use `readJSONLTolerant()` rather than `readJSONL()` to tolerate incomplete/truncated trailing lines. + +- **Rollout path ambiguity (dual CODEX_HOME)** — `CodexSessionAdapter.discoverSessions()` already scans both `~/.codex/sessions` and `~/.codex/codemie/home/sessions`. Multiple rollout files may match the same CWD + time window. The detector must take the newest match only (descriptors are already sorted newest-first by the adapter). + +- **Different base64 encoding format** — Claude: `item.source.data` (raw base64 string). Codex: `block.image_url = "data:;base64,"` (data URI). The new detector must split on the first `,` to separate MIME from base64. The MIME value from the data URI overrides any mime-types lookup. + +- **`event_msg.images` is always empty** — base64 is never stored in `event_msg`. Filename can be recovered from `event_msg.local_images[i]` (temp file path) or from the `` text wrapper in the `response_item`. The two records share the same `turn_id`; the new detector must correlate by `turn_id`. + +- **`turn_id` location** — based on pre-completed research, `turn_id` is carried in `internal_chat_message_metadata_passthrough`. This field name must be confirmed against `codex-message-types.ts` before implementation; the type definitions are the authoritative source. + +- **No existing test coverage for the new code surface** — `codexUploadsDetector.ts` starts with zero tests; the agent-dispatch branch in `index.ts` has no test. A fixture rollout JSONL with `input_image` blocks is needed (can be derived from existing fixtures at `tests/integration/session/fixtures/codex/`). + +- **Codex skill generator stale note** — `src/cli/commands/assistants/setup/generators/codex-skill-generator.ts` currently advises against using `CODEMIE_SESSION_ID`. Once session-based detection is transparent (no env var required), that note may confuse future maintainers; it should be removed or updated. + +- **`CODEMIE_AGENT` set by `BaseAgentAdapter`** — the agent-type check in `chat/index.ts` depends on `CODEMIE_AGENT` being present. Confirm `BaseAgentAdapter` sets it unconditionally before Codex launches; if not, the guard must fall through to the Claude path silently (not throw). + +- **`DetectedFile` import coupling** — `DetectedFile` is currently defined inside `claudeUploadsDetector.ts` and imported from there by `index.ts`. If the Codex detector imports `DetectedFile` from `claudeUploadsDetector.ts`, a circular-import risk exists if `claudeUploadsDetector.ts` is ever changed to import from the Codex side. Safest: export `DetectedFile` and `readFilesFromPaths` from a shared `uploads-types.ts` in the same directory. + +--- + +## 7. Summary for Complexity Assessment + +This task touches three architectural layers: the CLI/Command layer (`chat/index.ts`), a new CLI/Detector module (`codexUploadsDetector.ts`), and the Agent Plugin layer (`codex.plugin.ts`). The estimated file change surface is 3–4 files: one new file, two modified files, and potentially a shared types extraction. The SDK upload chain (`uploadFilesToCodeMie`, `client.files.bulkUpload`) and the `DetectedFile` contract require no changes, which significantly bounds the blast radius. + +The task introduces one genuinely novel pattern: rollout-based attachment extraction. The incremental-sync tick already demonstrates the rollout discovery algorithm (CWD realpath match, adapter.discoverSessions, parseSessionFile), so the core discovery logic can be cloned with minor adaptation. The novel piece is parsing the OpenAI Responses API content block format (`input_image` with data URI `image_url`) instead of the Claude base64 `source.data` shape. This requires careful handling of the data URI split and turn-based grouping using `turn_id`/`internal_chat_message_metadata_passthrough`. The exact field path for `turn_id` must be confirmed against `codex-message-types.ts` before coding — this is the primary investigation step remaining. + +Test coverage posture is weak for the new code: no existing test covers `codexUploadsDetector.ts` (it doesn't exist yet), and the agent-dispatch branch in `index.ts` is untested. Existing fixtures at `tests/integration/session/fixtures/codex/` provide a starting point, but a new fixture rollout containing `response_item` + `input_image` blocks must be created. The absence of test infrastructure for the new detector path is the most significant risk factor for regression — both for the Codex path and for inadvertent breakage of the Claude path if `detectFileUploadsFromSession` import or the dispatch logic is touched incorrectly. diff --git a/src/agents/plugins/claude/claude-message-types.ts b/src/agents/plugins/claude/claude-message-types.ts index 28a8677c4..15a4943fe 100644 --- a/src/agents/plugins/claude/claude-message-types.ts +++ b/src/agents/plugins/claude/claude-message-types.ts @@ -12,6 +12,7 @@ export interface ClaudeMessage { type: 'user' | 'assistant' | 'system' | string; subtype?: 'api_error' | string; // For system messages uuid: string; + promptId?: string; // Groups all messages belonging to one user prompt turn parentUuid?: string; sessionId: string; timestamp: string; diff --git a/src/agents/plugins/codex/codex-message-types.ts b/src/agents/plugins/codex/codex-message-types.ts index cd7df44e4..f8d5bc1a2 100644 --- a/src/agents/plugins/codex/codex-message-types.ts +++ b/src/agents/plugins/codex/codex-message-types.ts @@ -59,6 +59,25 @@ export interface CodexResponseItem { output?: string; // function_call_output: tool output } +/** + * The `message` sub-type of a response_item record — carries user/assistant + * content including text, images, and documents. Not modelled in CodexResponseItem + * because that interface focuses on function-call shapes. + */ +export interface CodexResponseItemMessage { + type: 'message'; + role: 'user' | 'assistant'; + content: CodexContentBlock[]; +} + +/** A single content block within CodexResponseItemMessage.content. */ +export interface CodexContentBlock { + type: 'input_text' | 'input_image' | 'input_file' | string; + text?: string; + /** Data URI: `"data:;base64,"` — present on input_image blocks. */ + image_url?: string; +} + /** event_msg record — user messages, token metering, task lifecycle, collaboration */ export interface CodexEventMsg { type: @@ -82,6 +101,14 @@ export interface CodexEventMsg { model_context_window?: number; } | null; agent_statuses?: Array<{ thread_id?: string; agent_role?: string }>; + /** Populated on user_message — base64 data URIs; typically empty (data lives in response_item). */ + images?: string[]; + /** Populated on user_message — temp file paths on disk for attached images. */ + local_images?: string[]; + text_elements?: Array<{ + byte_range?: { start: number; end: number }; + placeholder?: string; + }>; } export interface CodexTokenUsageBlock { diff --git a/src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts b/src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts index b6897e2e7..9f93d84b1 100644 --- a/src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts +++ b/src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts @@ -184,13 +184,13 @@ describe('fileResolver', () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; const messages: ClaudeMessage[] = [ - // Meta message with file name + // Real Claude Code JSONL: meta message holds BOTH base64 and [Image: source:] text { type: 'user', uuid: 'meta-1', - parentUuid: 'msg-1', + parentUuid: 'msg-parent', sessionId: mockSessionId, - timestamp: '2024-01-01T00:00:00Z', + timestamp: '2024-01-01T00:00:01Z', isMeta: true, message: { role: 'user', @@ -198,22 +198,6 @@ describe('fileResolver', () => { { type: 'text', text: '[Image: source: /path/to/screenshot.png]' - } - ] - } - } as ClaudeMessage, - // User message with image - { - type: 'user', - uuid: 'msg-1', - sessionId: mockSessionId, - timestamp: '2024-01-01T00:00:01Z', - message: { - role: 'user', - content: [ - { - type: 'text', - text: 'Look at this image' }, { type: 'image', @@ -225,6 +209,17 @@ describe('fileResolver', () => { } ] } + } as ClaudeMessage, + // Parent non-meta message — empty, as in real Claude Code JSONL + { + type: 'user', + uuid: 'msg-parent', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:00Z', + message: { + role: 'user', + content: [] + } } as ClaudeMessage ]; vi.mocked(readJSONL).mockResolvedValue(messages); @@ -253,13 +248,13 @@ describe('fileResolver', () => { vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockSession)); const messages: ClaudeMessage[] = [ - // Meta message with multiple file names + // Single meta message with filename text AND both attachment content items { type: 'user', uuid: 'meta-1', - parentUuid: 'msg-1', + parentUuid: 'msg-parent', sessionId: mockSessionId, - timestamp: '2024-01-01T00:00:00Z', + timestamp: '2024-01-01T00:00:01Z', isMeta: true, message: { role: 'user', @@ -267,19 +262,7 @@ describe('fileResolver', () => { { type: 'text', text: '[Image: source: /path/to/image1.png]\n[Document: source: /path/to/doc.pdf]' - } - ] - } - } as ClaudeMessage, - // User message with multiple attachments - { - type: 'user', - uuid: 'msg-1', - sessionId: mockSessionId, - timestamp: '2024-01-01T00:00:01Z', - message: { - role: 'user', - content: [ + }, { type: 'image', source: { @@ -298,6 +281,13 @@ describe('fileResolver', () => { } ] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-parent', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } } as ClaudeMessage ]; vi.mocked(readJSONL).mockResolvedValue(messages); @@ -313,7 +303,7 @@ describe('fileResolver', () => { expect(result[1].sizeBytes).toBeGreaterThan(0); }); - it('should only check last 2 user messages', async () => { + it('should detect attachment at any position within the current turn', async () => { vi.mocked(existsSync).mockReturnValue(true); const mockSession: Session = { id: mockSessionId, @@ -324,80 +314,375 @@ describe('fileResolver', () => { } as Session; vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockSession)); + // Session layout (chronological, as stored in JSONL): + // [0] assistant message ← turn boundary (scan stops here) + // [1] msg-parent ← non-meta empty (current turn) + // [2] meta-with-image ← isMeta, has base64 (current turn) ← MUST detect + // [3] meta-text-only ← isMeta, no attachment (current turn) + // [4] msg-tool-result ← non-meta tool_result (current turn) const messages: ClaudeMessage[] = [ - // Old user message (should be ignored) { - type: 'user', - uuid: 'msg-old', + type: 'assistant', + uuid: 'asst-1', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'assistant', content: 'Previous assistant reply' } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-parent', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'meta-with-image', + parentUuid: 'msg-parent', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:02Z', + isMeta: true, message: { role: 'user', content: [ + { type: 'text', text: '[Image: source: /path/to/photo.png]' }, { type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: 'old-image-data' - } + source: { type: 'base64', media_type: 'image/png', data: 'base64-photo-data' } } ] } } as ClaudeMessage, - // Recent user message 1 { type: 'user', - uuid: 'msg-1', + uuid: 'meta-text-only', sessionId: mockSessionId, - timestamp: '2024-01-01T00:00:01Z', + timestamp: '2024-01-01T00:00:03Z', + isMeta: true, + message: { role: 'user', content: [{ type: 'text', text: 'some context' }] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-tool-result', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:04Z', + message: { + role: 'user', + content: [{ type: 'tool_result', content: 'tool output' }] + } + } as ClaudeMessage + ]; + vi.mocked(readJSONL).mockResolvedValue(messages); + + const result = await detectFileUploadsFromSession(mockSessionId); + + expect(result).toHaveLength(1); + expect(result[0].data).toBe('base64-photo-data'); + expect(result[0].fileName).toBe('photo.png'); + expect(result[0].sizeBytes).toBeGreaterThan(0); + }); + + it('should not detect attachments from a previous turn', async () => { + vi.mocked(existsSync).mockReturnValue(true); + const mockSession: Session = { + id: mockSessionId, + correlation: { + status: 'matched', + agentSessionFile: mockAgentSessionFile + } + } as Session; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockSession)); + + // Turn 1: user uploaded an image, assistant replied + // Turn 2: user sends a plain message — no new upload + // detectFileUploadsFromSession must return [] (no current-turn attachment) + // promptId is the grouping key: turn-1 messages share 'prompt-prev', + // turn-2 message has 'prompt-curr' (no isMeta in turn 2) + const messages: ClaudeMessage[] = [ + { + type: 'user', + uuid: 'meta-old', + promptId: 'prompt-prev', + parentUuid: 'msg-old-parent', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:00Z', + isMeta: true, message: { role: 'user', content: [ + { type: 'text', text: '[Image: source: /old/image.png]' }, { type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: 'recent-image-1' - } + source: { type: 'base64', media_type: 'image/png', data: 'old-image-data' } } ] } } as ClaudeMessage, - // Recent user message 2 { type: 'user', - uuid: 'msg-2', + uuid: 'msg-old-parent', + promptId: 'prompt-prev', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + // Assistant reply — turn boundary + { + type: 'assistant', + uuid: 'asst-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:02Z', + message: { role: 'assistant', content: 'I see your image.' } + } as ClaudeMessage, + // Turn 2 — current turn, plain text only + { + type: 'user', + uuid: 'msg-current', + promptId: 'prompt-curr', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:03Z', + message: { + role: 'user', + content: [{ type: 'text', text: 'Just a text message, no new file' }] + } + } as ClaudeMessage + ]; + vi.mocked(readJSONL).mockResolvedValue(messages); + + const result = await detectFileUploadsFromSession(mockSessionId); + + expect(result).toEqual([]); + }); + + it('should detect image when base64 and filename are in separate isMeta messages sharing the same promptId (Bug B)', async () => { + vi.mocked(existsSync).mockReturnValue(true); + const mockSession: Session = { + id: mockSessionId, + correlation: { + status: 'matched', + agentSessionFile: mockAgentSessionFile + } + } as Session; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockSession)); + + // Real Claude Code JSONL structure (confirmed by live test): + // isMeta A — contains only base64 image data, no [Image: source:] text + // isMeta B — contains only [Image: source: /path] text, no base64 + // Both share the same promptId; the algorithm must cross-message-match them + const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'; + const messages: ClaudeMessage[] = [ + { + type: 'user', + uuid: 'msg-parent', + promptId: 'prompt-abc', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'meta-base64', + promptId: 'prompt-abc', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, + message: { + role: 'user', + content: [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: base64Data } } + ] + } + } as ClaudeMessage, + { + type: 'user', + uuid: 'meta-filename', + promptId: 'prompt-abc', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:02Z', + isMeta: true, message: { role: 'user', content: [ + { type: 'text', text: '[Image: source: /path/to/screenshot.png]' } + ] + } + } as ClaudeMessage + ]; + vi.mocked(readJSONL).mockResolvedValue(messages); + + const result = await detectFileUploadsFromSession(mockSessionId); + + expect(result).toHaveLength(1); + expect(result[0].fileName).toBe('screenshot.png'); + expect(result[0].data).toBe(base64Data); + expect(result[0].mediaType).toBe('image/png'); + expect(result[0].type).toBe('image'); + expect(result[0].sizeBytes).toBeGreaterThan(0); + }); + + it('should detect image even when an assistant tool_use message appears after isMeta messages within the same prompt (Bug A)', async () => { + vi.mocked(existsSync).mockReturnValue(true); + const mockSession: Session = { + id: mockSessionId, + correlation: { + status: 'matched', + agentSessionFile: mockAgentSessionFile + } + } as Session; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockSession)); + + // Real Claude Code JSONL order (confirmed by live test): + // msg-parent — non-meta user message (the triggering prompt) + // meta-base64 — isMeta, base64 only + // meta-filename — isMeta, [Image: source:] text only + // asst-tool-use — assistant tool_use (Claude starts executing before bash returns) + // tool-result — user tool_result message + // + // The old turn-boundary scan hit asst-tool-use and stopped before reaching meta-base64. + const base64Data = 'aW1hZ2VkYXRh'; + const messages: ClaudeMessage[] = [ + { + type: 'user', + uuid: 'msg-parent', + promptId: 'prompt-xyz', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'meta-base64', + promptId: 'prompt-xyz', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, + message: { + role: 'user', + content: [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: base64Data } } + ] + } + } as ClaudeMessage, + { + type: 'user', + uuid: 'meta-filename', + promptId: 'prompt-xyz', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:02Z', + isMeta: true, + message: { + role: 'user', + content: [ + { type: 'text', text: '[Image: source: /uploads/photo.png]' } + ] + } + } as ClaudeMessage, + // Assistant tool_use appears AFTER isMeta messages (Bug A trigger) + { + type: 'assistant', + uuid: 'asst-tool-use', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:03Z', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'process' } }] + } + } as ClaudeMessage, + { + type: 'user', + uuid: 'tool-result', + promptId: 'prompt-xyz', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:04Z', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }] + } + } as ClaudeMessage + ]; + vi.mocked(readJSONL).mockResolvedValue(messages); + + const result = await detectFileUploadsFromSession(mockSessionId); + + expect(result).toHaveLength(1); + expect(result[0].fileName).toBe('photo.png'); + expect(result[0].data).toBe(base64Data); + expect(result[0].sizeBytes).toBeGreaterThan(0); + }); + + it('should detect image at position 3 when tool-result messages are at positions 1 and 2', async () => { + vi.mocked(existsSync).mockReturnValue(true); + const mockSession: Session = { + id: mockSessionId, + correlation: { + status: 'matched', + agentSessionFile: mockAgentSessionFile + } + } as Session; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockSession)); + + // Reproduces the real evidence from EPMCDME-13907: + // uuid=c8d31c75 tool_result ← pos 1 (most recent, no attachment) + // uuid=a31514f8 isMeta, text ← pos 2 (no attachment) + // uuid=00b98ab8 isMeta, image ← pos 3 (was missed by old RECENT_MESSAGES_LIMIT=2) + // uuid=3677b4c3 non-meta, [] ← parent, empty + const messages: ClaudeMessage[] = [ + { + type: 'user', + uuid: 'msg-3677b4c3', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-00b98ab8', + parentUuid: 'msg-3677b4c3', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, + message: { + role: 'user', + content: [ + { type: 'text', text: '[Image: source: /uploads/diagram.png]' }, { type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: 'recent-image-2' - } + source: { type: 'base64', media_type: 'image/png', data: 'base64-diagram' } } ] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-a31514f8', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:02Z', + isMeta: true, + message: { role: 'user', content: [{ type: 'text', text: 'context only' }] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-c8d31c75', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:03Z', + message: { + role: 'user', + content: [{ type: 'tool_result', content: 'tool result value' }] + } } as ClaudeMessage ]; vi.mocked(readJSONL).mockResolvedValue(messages); const result = await detectFileUploadsFromSession(mockSessionId); - expect(result).toHaveLength(2); - expect(result[0].data).toBe('recent-image-2'); // Most recent first + expect(result).toHaveLength(1); + expect(result[0].fileName).toBe('diagram.png'); + expect(result[0].data).toBe('base64-diagram'); + expect(result[0].type).toBe('image'); expect(result[0].sizeBytes).toBeGreaterThan(0); - expect(result[1].data).toBe('recent-image-1'); - expect(result[1].sizeBytes).toBeGreaterThan(0); }); - it('should generate fallback filename when meta message is missing', async () => { + it('should generate fallback filename when filename annotation is absent from meta message', async () => { vi.mocked(existsSync).mockReturnValue(true); const mockSession: Session = { id: mockSessionId, @@ -411,9 +696,17 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, message: { role: 'user', content: [ @@ -452,9 +745,17 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, message: { role: 'user', content: [ @@ -494,9 +795,17 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, message: { role: 'user', content: [ @@ -539,9 +848,17 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, message: { role: 'user', content: [ @@ -584,9 +901,17 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, message: { role: 'user', content: [ @@ -627,9 +952,17 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, message: { role: 'user', content: [ @@ -671,9 +1004,17 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, message: { role: 'user', content: [ @@ -715,9 +1056,17 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, message: { role: 'user', content: [ diff --git a/src/cli/commands/assistants/chat/claudeUploadsDetector.ts b/src/cli/commands/assistants/chat/claudeUploadsDetector.ts index 54615e102..55ff46a30 100644 --- a/src/cli/commands/assistants/chat/claudeUploadsDetector.ts +++ b/src/cli/commands/assistants/chat/claudeUploadsDetector.ts @@ -11,12 +11,11 @@ import chalk from 'chalk'; import mime from 'mime-types'; import { logger } from '@/utils/logger.js'; import { readJSONL } from '@/agents/core/session/utils/jsonl-reader.js'; -import type { ClaudeMessage } from '@/agents/plugins/claude/claude-message-types.js'; +import type { ClaudeMessage, ContentItem } from '@/agents/plugins/claude/claude-message-types.js'; import type { Session } from '@/agents/core/session/types.js'; import { getSessionPath } from '@/agents/core/session/session-config.js'; const ATTACHMENT_PATH_PATTERN = /\[(Image|Document): source: ([^\]]+)\]/g; -const RECENT_MESSAGES_LIMIT = 2; const MAX_FILE_SIZE_MB = 100; const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; const BYTES_PER_KB = 1024; @@ -24,6 +23,7 @@ const BYTES_PER_MB = 1024 * 1024; const MESSAGE_TYPE = { USER: 'user', + ASSISTANT: 'assistant', TEXT: 'text', IMAGE: 'image', DOCUMENT: 'document' @@ -70,47 +70,6 @@ function extractFileName(filePath: string): string { return basename(filePath); } -function extractFileNamesFromMetaMessage(message: ClaudeMessage): string[] { - if (!message.isMeta || !message.parentUuid || !Array.isArray(message.message?.content)) { - return []; - } - - const fileNames: string[] = []; - for (const item of message.message.content) { - if (item.type === MESSAGE_TYPE.TEXT && item.text) { - const matches = item.text.matchAll(ATTACHMENT_PATH_PATTERN); - for (const match of matches) { - fileNames.push(extractFileName(match[2])); - } - } - } - - return fileNames; -} - -function buildAttachmentMap(messages: ClaudeMessage[]): Map { - const attachmentMap = new Map(); - const messagesWithAttachments = new Set(); - - for (const msg of messages) { - if (msg.type === MESSAGE_TYPE.USER && msg.uuid && Array.isArray(msg.message?.content)) { - const hasAttachment = msg.message.content.some(item => isAttachmentType(item.type)); - if (hasAttachment) { - messagesWithAttachments.add(msg.uuid); - } - } - } - - for (const msg of messages) { - const fileNames = extractFileNamesFromMetaMessage(msg); - if (fileNames.length > 0 && msg.parentUuid && messagesWithAttachments.has(msg.parentUuid)) { - const existing = attachmentMap.get(msg.parentUuid) || []; - attachmentMap.set(msg.parentUuid, [...existing, ...fileNames]); - } - } - - return attachmentMap; -} function readSessionMetadata(sessionId: string): Session | null { const sessionPath = getSessionPath(sessionId); @@ -150,22 +109,6 @@ function extractAgentSessionFile(session: Session): string | null { return agentSessionFile; } -function getRecentUserMessages(messages: ClaudeMessage[]): ClaudeMessage[] { - const recentMessages: ClaudeMessage[] = []; - - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.type === MESSAGE_TYPE.USER && msg.uuid) { - recentMessages.push(msg); - if (recentMessages.length >= RECENT_MESSAGES_LIMIT) { - break; - } - } - } - - return recentMessages; -} - function processFileItem( item: { type: string; source?: { type?: string; data?: string; media_type?: string } }, fileName: string, @@ -211,42 +154,59 @@ function processFileItem( } } -function extractFileContentFromMessages( - messages: ClaudeMessage[], - attachmentMap: Map -): DetectedFile[] { - const detectedFiles: DetectedFile[] = []; - const recentUserMessages = getRecentUserMessages(messages); - - if (recentUserMessages.length === 0) { - logger.debug(`${LOG_PREFIX} No user messages found`); +function extractFileContentFromMessages(messages: ClaudeMessage[]): DetectedFile[] { + // Step 1: find the most recent non-meta user message and capture its promptId. + // promptId groups all JSONL entries belonging to one user prompt turn. + let foundAnchor = false; + let anchorPromptId: string | undefined; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.type === MESSAGE_TYPE.USER && !msg.isMeta) { + foundAnchor = true; + anchorPromptId = msg.promptId; + break; + } + } + if (!foundAnchor) { + logger.debug(`${LOG_PREFIX} Checked current-turn messages`, { filesFound: 0 }); return []; } - for (const userMessage of recentUserMessages) { - if (!Array.isArray(userMessage.message?.content)) { - continue; - } + // Step 2: collect all isMeta messages that share the anchor's promptId. + // In real Claude Code JSONL the base64 data and the [Image: source:] filename + // text live in separate isMeta entries — both carry the same promptId. + const promptMessages = messages.filter( + msg => msg.isMeta && msg.promptId === anchorPromptId && Array.isArray(msg.message?.content) + ); - const fileNames = attachmentMap.get(userMessage.uuid!) ?? []; - let fileIndex = 0; + // Step 3: gather every filename annotation across all those messages first. + const fileNames: string[] = []; + for (const msg of promptMessages) { + for (const item of msg.message!.content as ContentItem[]) { + if (item.type === MESSAGE_TYPE.TEXT && item.text) { + const matches = item.text.matchAll(ATTACHMENT_PATH_PATTERN); + for (const match of matches) { + fileNames.push(extractFileName(match[2])); + } + } + } + } - for (const item of userMessage.message.content) { + // Step 4: collect every base64 attachment item and match by positional index. + const detectedFiles: DetectedFile[] = []; + let fileIndex = 0; + for (const msg of promptMessages) { + for (const item of msg.message!.content as ContentItem[]) { if (isAttachmentType(item.type)) { const fileName = fileNames[fileIndex] ?? generateFallbackFileName(detectedFiles.length, fileIndex); - const detectedFile = processFileItem(item, fileName, userMessage.uuid); - - if (detectedFile) { - detectedFiles.push(detectedFile); - } - + const detectedFile = processFileItem(item, fileName, msg.uuid); + if (detectedFile) detectedFiles.push(detectedFile); fileIndex++; } } } - logger.debug(`${LOG_PREFIX} Checked recent messages`, { - messagesChecked: recentUserMessages.length, + logger.debug(`${LOG_PREFIX} Checked current-turn messages`, { filesFound: detectedFiles.length }); @@ -412,8 +372,7 @@ export async function detectFileUploadsFromSession( agentSessionFile }); - const attachmentMap = buildAttachmentMap(messages); - const detectedFiles = extractFileContentFromMessages(messages, attachmentMap); + const detectedFiles = extractFileContentFromMessages(messages); logDetectedFiles(detectedFiles, quiet); diff --git a/src/cli/commands/assistants/chat/codexUploadsDetector.ts b/src/cli/commands/assistants/chat/codexUploadsDetector.ts new file mode 100644 index 000000000..facbf75f6 --- /dev/null +++ b/src/cli/commands/assistants/chat/codexUploadsDetector.ts @@ -0,0 +1,288 @@ +/** + * Codex-specific file attachment detector. + * + * Discovers the active Codex rollout by scanning Codex sessions directories + * (via getCodexDiscoverySessionRoots) for a rollout whose session_meta.cwd + * resolves to process.cwd(), then extracts input_image/input_file blocks from + * the most recent user response_item record. + * + * Uses direct JSONL scanning rather than CodexSessionAdapter because the adapter + * requires AgentMetadata.dataPaths.home which is not available in the CLI layer. + * Codex hooks also do not fire reliably, so hook-based correlation is skipped. + */ + +import { realpath as fsRealpath, readdir, stat } from 'fs/promises'; +import { basename, join } from 'path'; +import chalk from 'chalk'; +import { logger } from '@/utils/logger.js'; +import { readJSONLTolerant } from '@/agents/core/session/utils/jsonl-reader.js'; +import { getCodexDiscoverySessionRoots } from '@/agents/plugins/codex/codex.paths.js'; +import { isCodexInjectedUserText } from '@/agents/plugins/codex/session/codex-user-prompt.js'; +import type { + CodexRolloutRecord, + CodexSessionMeta, + CodexEventMsg, + CodexResponseItemMessage, + CodexContentBlock, +} from '@/agents/plugins/codex/codex-message-types.js'; +import type { DetectedFile } from './claudeUploadsDetector.js'; + +const LOG_PREFIX = '[codexUploadsDetector]'; +const ROLLOUT_FILENAME = /^rollout-.*\.jsonl$/; +const IMAGE_WRAPPER_PATTERN = //; +const MAX_FILE_SIZE_MB = 100; +const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; +const BYTES_PER_MB = 1024 * 1024; +const BYTES_PER_KB = 1024; +const DEFAULT_MEDIA_TYPE = 'application/octet-stream'; +const MS_PER_DAY = 86_400_000; + +export interface DetectCodexFileUploadsOptions { + cwd: string; + quiet?: boolean; +} + +async function safeRealpath(p: string): Promise { + try { + return await fsRealpath(p); + } catch { + return p; + } +} + +async function scanRecentRollouts( + sessionsPath: string, + nowMs: number +): Promise> { + const candidates: Array<{ filePath: string; mtime: number }> = []; + + for (let daysBack = 0; daysBack <= 1; daysBack++) { + const d = new Date(nowMs - daysBack * MS_PER_DAY); + const year = d.getFullYear().toString(); + const month = (d.getMonth() + 1).toString().padStart(2, '0'); + const day = d.getDate().toString().padStart(2, '0'); + const dayPath = join(sessionsPath, year, month, day); + + let files: string[]; + try { + files = await readdir(dayPath); + } catch { + continue; + } + + for (const file of files) { + if (!ROLLOUT_FILENAME.test(file)) continue; + const filePath = join(dayPath, file); + try { + const s = await stat(filePath); + candidates.push({ filePath, mtime: s.mtime.getTime() }); + } catch { + // skip unreadable + } + } + } + + return candidates; +} + +async function findMatchingRollout(cwdReal: string, nowMs: number): Promise { + const roots = getCodexDiscoverySessionRoots(); + if (!roots.length) { + logger.debug(`${LOG_PREFIX} No Codex session directories found`); + return null; + } + + const allCandidates: Array<{ filePath: string; mtime: number }> = []; + for (const root of roots) { + const candidates = await scanRecentRollouts(root.sessionsPath, nowMs); + allCandidates.push(...candidates); + } + + allCandidates.sort((a, b) => b.mtime - a.mtime); + + for (const { filePath } of allCandidates) { + const records = await readJSONLTolerant(filePath, LOG_PREFIX); + const metaRecord = records.find((r) => r.type === 'session_meta'); + if (!metaRecord) continue; + + const sessionMeta = metaRecord.payload as CodexSessionMeta; + if (!sessionMeta.cwd) continue; + + const metaReal = await safeRealpath(sessionMeta.cwd); + if (metaReal === cwdReal) { + logger.debug(`${LOG_PREFIX} Matched rollout`, { filePath, cwd: sessionMeta.cwd }); + return filePath; + } + } + + return null; +} + +function processImageBlock(block: CodexContentBlock, fileName: string): DetectedFile | null { + if (!block.image_url) { + logger.warn(`${LOG_PREFIX} input_image block missing image_url`, { fileName }); + return null; + } + + const commaIdx = block.image_url.indexOf(','); + if (commaIdx === -1) { + logger.warn(`${LOG_PREFIX} Malformed data URI in input_image block`, { fileName }); + return null; + } + + const prefix = block.image_url.slice(0, commaIdx); + const base64Data = block.image_url.slice(commaIdx + 1); + + if (!base64Data) { + logger.warn(`${LOG_PREFIX} Empty data URI payload in input_image block`, { fileName }); + return null; + } + + const mimeMatch = /data:([^;]+);base64/.exec(prefix); + const mediaType = mimeMatch?.[1] ?? DEFAULT_MEDIA_TYPE; + + // Estimate decoded size without allocating the full buffer (Buffer.from on a large + // payload can OOM before the size guard runs, silently dropping a valid attachment). + const paddingChars = base64Data.endsWith('==') ? 2 : base64Data.endsWith('=') ? 1 : 0; + const sizeBytes = Math.ceil(base64Data.length * 3 / 4) - paddingChars; + + if (sizeBytes > MAX_FILE_SIZE_BYTES) { + logger.warn(`${LOG_PREFIX} File exceeds size limit, skipping`, { + fileName, + sizeMB: (sizeBytes / BYTES_PER_MB).toFixed(2), + limit: MAX_FILE_SIZE_MB, + }); + return null; + } + + return { + fileName, + data: base64Data, + mediaType, + type: 'image', + sizeBytes, + }; +} + +function extractAttachments(records: CodexRolloutRecord[]): DetectedFile[] { + let targetResponseItem: CodexResponseItemMessage | null = null; + let targetEventMsg: (CodexEventMsg & { local_images?: string[] }) | null = null; + + // First pass: locate the most-recent user response_item that carries attachments. + let responseItemIndex = -1; + for (let i = records.length - 1; i >= 0; i--) { + const record = records[i]; + if (record.type !== 'response_item') continue; + const payload = record.payload as unknown as CodexResponseItemMessage; + if ( + payload.type === 'message' && + payload.role === 'user' && + Array.isArray(payload.content) && + payload.content.some((b) => b.type === 'input_image' || b.type === 'input_file') + ) { + targetResponseItem = payload; + responseItemIndex = i; + break; + } + } + + if (!targetResponseItem) { + logger.debug(`${LOG_PREFIX} No user response_item with attachments found`); + return []; + } + + // Second pass: find the matching event_msg at or before the response_item's position, + // so a later follow-up message never severs the filename chain. + for (let i = responseItemIndex; i >= 0; i--) { + const record = records[i]; + if (record.type !== 'event_msg') continue; + const payload = record.payload as CodexEventMsg & { local_images?: string[] }; + if ( + payload.type === 'user_message' && + typeof payload.message === 'string' && + !isCodexInjectedUserText(payload.message) + ) { + targetEventMsg = payload; + break; + } + } + + const detectedFiles: DetectedFile[] = []; + const content = targetResponseItem.content; + // imageOnlyIndex tracks position within local_images (images-only array) separately + // from input_file blocks, which do not appear in local_images. + let imageOnlyIndex = 0; + + for (let i = 0; i < content.length; i++) { + const block = content[i]; + if (block.type !== 'input_image' && block.type !== 'input_file') continue; + + if (block.type === 'input_image') { + let fileName: string | undefined; + if (i > 0) { + const prev = content[i - 1]; + if (prev.type === 'input_text' && typeof prev.text === 'string') { + const match = IMAGE_WRAPPER_PATTERN.exec(prev.text); + if (match) fileName = basename(match[1]); + } + } + if (!fileName) { + const localPath = targetEventMsg?.local_images?.[imageOnlyIndex]; + if (localPath) fileName = basename(localPath); + } + fileName = fileName ?? `attachment_${imageOnlyIndex}`; + imageOnlyIndex++; + + const detectedFile = processImageBlock(block, fileName); + if (detectedFile) detectedFiles.push(detectedFile); + } + // input_file blocks are not yet uploadable (no non-image upload path); skip silently. + } + + logger.debug(`${LOG_PREFIX} Extracted attachments`, { count: detectedFiles.length }); + return detectedFiles; +} + +/** + * Detect file uploads from the active Codex rollout. + * + * Finds the most recent rollout in the Codex sessions directories whose + * session_meta.cwd resolves to `options.cwd`, then extracts any files + * attached in the most recent user turn. + */ +export async function detectCodexFileUploads( + options: DetectCodexFileUploadsOptions +): Promise { + const { cwd, quiet = false } = options; + + logger.debug(`${LOG_PREFIX} Detecting file uploads from Codex rollout`, { cwd }); + + try { + const nowMs = Date.now(); + const cwdReal = await safeRealpath(cwd); + const rolloutPath = await findMatchingRollout(cwdReal, nowMs); + + if (!rolloutPath) { + logger.debug(`${LOG_PREFIX} No matching rollout found for cwd`, { cwdReal }); + return []; + } + + const records = await readJSONLTolerant(rolloutPath, LOG_PREFIX); + const detectedFiles = extractAttachments(records); + + if (!quiet && detectedFiles.length > 0) { + console.log(chalk.cyan(`\n📎 Detected ${detectedFiles.length} file(s) with content:`)); + detectedFiles.forEach((file, index) => { + const sizeKB = Math.round(file.sizeBytes / BYTES_PER_KB); + console.log(chalk.dim(` ${index + 1}. ${file.fileName} (${file.mediaType}, ${sizeKB} KB)`)); + }); + console.log(''); + } + + logger.debug(`${LOG_PREFIX} Detection complete`, { filesDetected: detectedFiles.length }); + return detectedFiles; + } catch (error) { + logger.debug(`${LOG_PREFIX} Failed to detect file uploads`, { error }); + return []; + } +} diff --git a/src/cli/commands/assistants/chat/index.ts b/src/cli/commands/assistants/chat/index.ts index bb51d4922..f28748f12 100644 --- a/src/cli/commands/assistants/chat/index.ts +++ b/src/cli/commands/assistants/chat/index.ts @@ -22,6 +22,7 @@ import { appendConversationTurn } from './historyPersister.js'; import { isExitCommand, enableVerboseMode } from './utils.js'; import type { ChatCommandOptions, SingleMessageOptions } from './types.js'; import { detectFileUploadsFromSession, readFilesFromPaths, type DetectedFile } from './claudeUploadsDetector.js'; +import { detectCodexFileUploads } from './codexUploadsDetector.js'; /** Assistant label color */ const ASSISTANT_LABEL_COLOR = [177, 185, 249] as const; @@ -95,9 +96,18 @@ async function chatWithAssistant( // Collect files from session and CLI paths let detectedFiles: DetectedFile[] = []; - // 1. Detect files from session (if conversationId exists) - if (conversationId) { - detectedFiles = await detectFileUploadsFromSession(conversationId, { quiet: false }); + // 1. Detect files from the agent session. + // CODEMIE_AGENT selects the detection strategy: + // - 'codex': scan Codex rollout JSONL by CWD match (Codex hooks are non-functional) + // - default: read Claude session JSONL via CODEMIE_SESSION_ID → correlation → agentSessionFile + const agentName = process.env.CODEMIE_AGENT; + if (agentName === 'codex') { + detectedFiles = await detectCodexFileUploads({ cwd: process.cwd(), quiet: false }); + } else { + const claudeSessionId = process.env.CODEMIE_SESSION_ID; + if (claudeSessionId) { + detectedFiles = await detectFileUploadsFromSession(claudeSessionId, { quiet: false }); + } } // 2. Read files from --file paths (if provided)