Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/create-tag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ on:
- memory-consolidate
- opencode
- openwiki
- pdf
- pi
- provider-anthropic
- provider-claude-code
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ on:
- 'memory-consolidate/v*'
- 'opencode/v*'
- 'openwiki/v*'
- 'pdf/v*'
- 'pi/v*'
- 'provider-anthropic/v*'
- 'provider-claude-code/v*'
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ npx skills add iii-hq/iii --all
| [`worktree`](worktree/) | Rust | Git worktree lifecycle for parallel agents — `worktree::*` mint, claim, and track isolated worktrees per repo, emit six lifecycle trigger types, and land branches back through a per-repo FIFO queue (rebase, test gate, ff-only merge). |
| [`github`](github/) | Rust | GitHub CLI (`gh`) as an iii worker — typed `github::pr/issue/repo/run/workflow/release/search::*` functions plus `github::exec` argv passthrough and `github::api` for any GitHub REST endpoint. |
| [`openwiki`](openwiki/) | Node | Source-grounded markdown wiki for any git repository — a lead agent plans the index and writer sub-agents store cited pages via `openwiki::write-page`, with router and heuristic fallback tiers, incremental refresh from git diffs on a per-wiki cron schedule, and a browser UI + JSON API under `/openwiki`. |
| [`pdf`](pdf/) | Rust | Read PDFs locally — `pdf::classify` routes text-based versus scanned in tens of milliseconds and names the pages that still need OCR, `pdf::to-markdown` converts with headings, lists and tables intact, and `pdf::extract-items` / `::extract-regions` expose positions and the text inside a box. Ships a console page. |

## SDK

Expand Down
3 changes: 3 additions & 0 deletions console/web/src/components/chat/AttachmentButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export function AttachmentButton({
size: f.size,
type: f.type || 'application/octet-stream',
dataUrl: await readPreview(f),
// Kept so the send path can hand the bytes to a worker that reads this
// kind of file (PDFs go through `pdf::to-markdown`). Not persisted.
file: f,
})),
)
onAttach(attachments)
Expand Down
66 changes: 66 additions & 0 deletions console/web/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ import { useConversationsCtxOptional } from '@/lib/conversations-context'
import { syncEditorWorkspace } from '@/lib/editor-sync'
import { expandFileMentions, parseFileMentions } from '@/lib/file-mentions'
import { formatStopReason } from '@/lib/format-stop-reason'
import {
expandPdfAttachments,
isPdfAttachment,
summaryLabel,
} from '@/lib/pdf-attachments'
import { newMessageId } from '@/lib/session-id'
import { cn } from '@/lib/utils'
import { fetchDefaultWorkingDir, validateWorkspaceDir } from '@/lib/working-dir'
Expand Down Expand Up @@ -500,6 +505,29 @@ export function ChatView({
).blocks
}
}
// Same expansion as the live send path: a queued message's PDFs have
// to reach the agent as markdown too, or editing a queued message
// would silently drop the document it carried.
if (
backend.id === 'real' &&
payload.attachments.some(isPdfAttachment)
) {
const expanded = await expandPdfAttachments(payload.attachments)
if (expanded.blocks.length > 0) {
attachedBlocks = [...(attachedBlocks ?? []), ...expanded.blocks]
}
// Same reporting as the live send path. Staying silent here would let
// an edited queued message lose its document with no explanation.
for (const failure of expanded.failures) {
onAppendMessage(
conversationId,
makeSystemNotice(
`could not read ${failure.name} — ${failure.reason}`,
'warn',
),
)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
await backend.editQueued?.(
conversationId,
Expand Down Expand Up @@ -995,6 +1023,44 @@ export function ChatView({
}
}

// A PDF is not text: read as bytes it reaches the model as noise, so the
// `pdf` worker converts it on this machine and the markdown is appended
// as another attachment block. Failures never block the send — an
// unreadable document becomes a placeholder block plus a warn notice, so
// the model knows it was handed something it could not read.
if (backend.id === 'real' && payload.attachments.some(isPdfAttachment)) {
const expanded = await expandPdfAttachments(payload.attachments)
if (expanded.blocks.length > 0) {
attachedBlocks = [...(attachedBlocks ?? []), ...expanded.blocks]
}
// Relabel the chip with what the worker made of the document. The
// expansion runs before the model is called, so it never shows up as a
// function call — without this a person has no way to tell the PDF was
// read at all.
if (expanded.read.length > 0 && !willQueue) {
const byId = new Map(expanded.read.map((r) => [r.id, r]))
onPatchMessage(conversationId, userMsg.id, {
// `file` is dropped here as well as relabelled. It has done its job
// by now, and keeping it would hold the whole document in memory
// for as long as the conversation stays open.
attachments: (userMsg.attachments ?? []).map(({ file, ...a }) => {
void file
const summary = byId.get(a.id)
return summary ? { ...a, name: summaryLabel(a.name, summary) } : a
}),
})
}
for (const failure of expanded.failures) {
onAppendMessage(
conversationId,
makeSystemNotice(
`could not read ${failure.name} — ${failure.reason}`,
'warn',
),
)
}
}

// Mid-stream send (MOT-3837): a turn is already streaming, so the
// harness queues the message and delivers it when the stream ends. No
// second stream loop — the live one keeps rendering. The draft chip
Expand Down
Loading
Loading