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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M53FDJP7K240NMG7Q4E5B154
anchor: function text
created: 2026-10-16T23:05:41Z
norm: '1'
sig: 11c5a30588ada982
body_hash: b3cafd9a4f9acb15
raw_hash: cce4e0f9162ae184
lines: 31-40
---

pdf.js page text must be read through the stream's reader; WebKit's ReadableStream isn't async-iterable, so getTextContent throws in the desktop webview
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M53FDJPMP928VPQA5JD1CVHC
anchor: function read
created: 2026-10-16T23:05:41Z
norm: '1'
sig: e902509aeec5b6f5
body_hash: 08302543447ad2e9
raw_hash: c349d3cc21691287
lines: 72-83
---

detect PDFs by MIME type only; a withdrawn PDF returns to the composer as its extracted text typed text/plain under its .pdf name
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-14T12:13:47Z
norm: '1'
sig: 021fb596db81e6d0
body_hash: d63795af6ea9f4f1
raw_hash: 3c9928c1a31b3a70
vouched: 2026-10-15T23:58:07Z
body_hash: 98e1d861f9b08883
raw_hash: 3afc751c85317c2d
vouched: 2026-10-16T23:05:45Z
lines: 1-92
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-15T10:29:13Z
norm: '1'
sig: 021fb596db81e6d0
body_hash: d63795af6ea9f4f1
raw_hash: 3c9928c1a31b3a70
vouched: 2026-10-15T23:58:07Z
body_hash: 98e1d861f9b08883
raw_hash: 3afc751c85317c2d
vouched: 2026-10-16T23:05:45Z
lines: 1-94
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M53FDJQ4C0BGDGXNAY5GK09T
anchor: '@file'
created: 2026-10-16T23:05:41Z
norm: '1'
sig: ce515648b5bedef2
body_hash: fb0394625d6bac4e
raw_hash: 1a90d920d15ad713
lines: 1-912
---

a file block's cache key includes its text, since the block opens that text and names can repeat across messages
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@xterm/addon-fit": "catalog:ui",
"@xterm/xterm": "catalog:ui",
"electrobun": "catalog:desktop",
"pdfjs-dist": "catalog:ui",
"react": "catalog:ui",
"react-dom": "catalog:ui"
},
Expand Down
53 changes: 51 additions & 2 deletions apps/app/src/attachments.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { PDFPageProxy } from "pdfjs-dist";

import type { Image, TextFile } from "@ace/channel/protocol";
import type { Attachment } from "@ace/ui";

/** Matches the channel's per-message limit, with room for the message's text. */
const BUDGET = 1_400_000;
const EDGE = 2000;

type TextContent = Awaited<ReturnType<PDFPageProxy["getTextContent"]>>;

async function encode(url: string, edge: number, quality: number): Promise<Image> {
const bitmap = await createImageBitmap(await (await fetch(url)).blob());
const scale = Math.min(1, edge / Math.max(bitmap.width, bitmap.height));
Expand All @@ -23,18 +27,63 @@ async function encode(url: string, edge: number, quality: number): Promise<Image
return { mimeType: "image/jpeg", data: btoa(binary) };
}

/** Reads the stream itself: getTextContent iterates it, and WebKit's streams aren't iterable. */
async function text(page: PDFPageProxy): Promise<string> {
const reader: ReadableStreamDefaultReader<TextContent> = page.streamTextContent().getReader();
let lines = "";
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
for (const item of chunk.value.items) {
if ("str" in item) lines += item.str + (item.hasEOL ? "\n" : "");
}
}
return lines;
}

/**
* Providers' document inputs aren't in pi's message format, so a PDF goes to models as its pages'
* text, like any text file. Scanned pages have no text and can't be read this way.
*/
async function pdf(file: File): Promise<TextFile> {
const [{ getDocument, GlobalWorkerOptions }, worker] = await Promise.all([
import("pdfjs-dist"),
import("pdfjs-dist/build/pdf.worker.min.mjs?url"),
]);
GlobalWorkerOptions.workerSrc = worker.default;
const task = getDocument({ data: new Uint8Array(await file.arrayBuffer()) });
try {
const doc = await task.promise.catch(() => {
throw new Error(`${file.name} couldn't be read as a PDF`);
});
const pages: string[] = [];
for (let number = 1; number <= doc.numPages; number++) {
pages.push(await text(await doc.getPage(number)));
}
const joined = pages.join("\n\n").trim();
if (!joined) {
throw new Error(`${file.name} has no text to read; scanned PDFs can't be attached`);
}
return { name: file.name, text: joined };
} finally {
await task.destroy();
}
}

/** Models read files as text, so only valid UTF-8 without NUL bytes can be sent. */
async function read(file: File): Promise<TextFile> {
// A withdrawn PDF returns to the composer as its text, typed text/plain.
if (file.type === "application/pdf") return pdf(file);
const decoder = new TextDecoder("utf-8", { fatal: true });
const text = await file.arrayBuffer().then((bytes) => decoder.decode(bytes)).catch(() => "\0");
if (text.includes("\0")) {
throw new Error(`${file.name} isn't a text file; only images and text files can be attached`);
throw new Error(
`${file.name} isn't a text file; only images, text files, and PDFs can be attached`,
);
}
return { name: file.name, text };
}

/**
* Read attached text files, then re-encode attached images as JPEG, shrinking until the message
* Read attached text files and PDFs, then re-encode attached images as JPEG, shrinking until the message
* fits the channel's limit. Re-encoding also strips metadata such as location from photos.
*/
export async function attachments(
Expand Down
3 changes: 2 additions & 1 deletion apps/app/src/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,12 @@ export function toEvents(
type: "image" as const,
image: `data:${image.mimeType};base64,${image.data}`,
})),
// The block shows only the name; contents stay in the transcript, not a URL.
// Contents travel in the transcript, not at a URL; the block opens them.
...(item.files || []).map((file) => ({
type: "file" as const,
name: file.name,
file: "",
text: file.text,
})),
],
} as Event;
Expand Down
28 changes: 28 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ is one pi conversation in that Session.
edit or deletion of an entry wins. Agent replies to the earlier text stay in the transcript
and in later context, and an edit never reruns the agent. Watches that opt in receive `edited`
live; every watch replays a message with its newest text and when it was edited.
- **Attachments live in the message.** Images and files travel on `say` and `ask` and are stored
in the human message's pi entry, so every model, channel placement, and teammate reads them
without a second store or a workspace. Files are text: `<attachment name="...">` blocks after
the message's own text. The app sends a PDF as its pages' text, extracted with pdf.js before
sending, because pi's messages carry no provider document input; scanned PDFs and other binary
files are refused. A message's images and file text share a 1.5 MB budget under the Durable
Object row cap, and the app opens a sent file's text from its timeline block. The budget is
bytes, not tokens: a file that alone exceeds the model's context fails the run, and later runs,
until its message is deleted.
- **Tool progress is live only.** Watches that opt in receive `progress` events: the bounded tail
of each running call's output from pi's live tool slots, at most every half second per watch,
each replacing the last. Attachment rebuilds them from pi's snapshot, and the call's durable
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"lucide-static": "1.48.0",
"markdown-it": "14.1.1",
"motion": "12.23.24",
"pdfjs-dist": "6.3.289",
"prosemirror-commands": "1.7.1",
"prosemirror-highlight": "0.15.1",
"prosemirror-history": "1.5.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/channel/src/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export function section(room: Room): PromptSection {
"you act only when someone invokes you; answer the person who did. Write replies as plain",
"text without a name prefix: the channel shows who wrote each message.",
'Files people attach follow their message as <attachment name="..."> blocks holding the',
"whole file; they are not on disk, so read them there.",
"whole file, or a PDF's extracted text; they are not on disk, so read them there.",
"",
"To wait for another channel's agent, say in this chat what you are waiting for and end your",
"run; its message with invoke set wakes you. Never wait for another channel in a shell sleep or",
Expand Down
46 changes: 38 additions & 8 deletions packages/ui/src/components/blocks/file-view.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,47 @@
import { useState } from "react";

import { cn } from "../../lib/utils";
import { Dialog, DialogContent, DialogTitle } from "../../ui/dialog";
import { FileView as Contents } from "../file/file-view";
import { FILE_HEIGHT } from "./constants";

function FileView({ name, size }: { name: string; size: string }) {
return (
<div
className="flex min-w-0 items-center gap-3 overflow-hidden rounded-lg squircle border border-border px-3 contain-strict"
style={{ blockSize: FILE_HEIGHT }}
>
function FileView({ name, text }: { name: string; text?: string }) {
let [open, setOpen] = useState(false);
let lines = text ? text.split("\n").length - (text.endsWith("\n") ? 1 : 0) : 0;
let card = (
<>
<div className="shrink-0 text-lg">📄</div>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm text-foreground">{name}</span>
<span className="truncate text-xs text-muted-foreground">{size}</span>
<span className="truncate text-xs text-muted-foreground">
{text === undefined ? "" : `${lines.toLocaleString()} ${lines === 1 ? "line" : "lines"}`}
</span>
</div>
</div>
</>
);
let className =
"flex w-full min-w-0 items-center gap-3 overflow-hidden rounded-lg squircle border border-border px-3 text-left contain-strict";
if (text === undefined) {
return <div className={className} style={{ blockSize: FILE_HEIGHT }}>{card}</div>;
}
return (
<>
<button
type="button"
aria-label={`Open ${name}`}
className={cn(className, "cursor-pointer hover:bg-muted/50")}
style={{ blockSize: FILE_HEIGHT }}
onClick={() => setOpen(true)}
>
{card}
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="top-4 h-(--dialog-max) max-w-[calc(100vw-2rem)] gap-0 overflow-hidden p-0 [--dialog-top:1rem] sm:max-w-4xl">
<DialogTitle className="sr-only">{name}</DialogTitle>
<Contents path={name} source={text} className="[&>header]:pr-10" />
</DialogContent>
</Dialog>
</>
);
}

Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/components/blocks/file.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import type { Block } from "../../lib/block";
import { FileView } from "./file-view";
import { FILE_HEIGHT } from "./constants";

/** File attachment block with fixed height. */
function file(name: string, size: string): Block {
/** File attachment block with fixed height; one carrying its contents opens them. */
function file(name: string, text?: string): Block {
return {
measure(width) {
return { height: FILE_HEIGHT, fit: width };
},
render() {
return <FileView name={name} size={size} />;
return <FileView name={name} text={text} />;
},
};
}
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/components/timeline/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ function key(
...chunk.images.flatMap(img => [img.src, img.width, img.height, img.alt]),
);
case "file":
return joinKey("file", mode, chunk.name);
return joinKey("file", mode, chunk.name, chunk.text);
case "tool-group":
return joinKey(
"tools",
Expand Down Expand Up @@ -263,7 +263,7 @@ function build(
case "image-grid":
return [gridBlock(chunk.images)];
case "file":
return [fileBlock(chunk.name, "")];
return [fileBlock(chunk.name, chunk.text)];
case "tool-group":
return tools(chunk.tools, expanded, expandedResults, settling);
case "commit":
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/lib/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ type Chunk =
| { kind: "diff"; patch: string }
| { kind: "image"; src: string; width: number; height: number; alt?: string }
| { kind: "image-grid"; images: ImageNode[] }
| { kind: "file"; name: string; url: string }
| { kind: "file"; name: string; url: string; text?: string }
| { kind: "tool-group"; tools: ToolNode[] }
| { kind: "commit"; sha: string; message: string; authors: string[]; url?: string }
| { kind: "pr"; pr: string; url: string; title: string; draft: boolean }
Expand Down Expand Up @@ -268,6 +268,7 @@ function structure(
kind: "file",
name: c.name,
url: typeof c.file === "string" ? c.file : c.file.url,
text: c.text,
});
break;
case "tool":
Expand Down
8 changes: 7 additions & 1 deletion packages/ui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,13 @@ export namespace Event {
export namespace Content {
export type Text = { type: "text"; text: string; parts?: Part[] };
export type Image = { type: "image"; image: string | { url: string } };
export type File = { type: "file"; name: string; file: string | { url: string } };
export type File = {
type: "file";
name: string;
file: string | { url: string };
/** Contents carried with the message, which the block opens in a file view. */
text?: string;
};
export type Tool = { type: "tool" } & import("./types").Tool;
}

Expand Down