From dfb031c527fe388fcb03ad65ba8160903d53e728 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Fri, 16 Oct 2026 19:12:51 -0400 Subject: [PATCH 1/3] fix(channel): refuse attachments that can't fit the chat's model, and explain context overflows A message whose attached files exceed the model's context failed its run and every later one, since the transcript keeps it. ask and say now estimate attached files' tokens from their UTF-8 bytes and refuse a message over three quarters of the model's context window, naming the files and the limit. A run that still overflows, such as one over a message stored by 0.0.48, ends with an error that says to delete the message with the large attachment or pick a larger model. Why: packages/channel/src/room.ts#function checkContext | estimate from UTF-8 bytes, not chars; pi-ai's chars/4 undercounts non-Latin text by ~2x, which is how multilingual PDFs overflowed Why: packages/channel/src/channel.ts#function failed | overflow errors are explained in the projection, not stored, so replies already stored by older channels get the recovery hint too --- packages/channel/src/channel.ts | 37 +++++++++++++++++++++++++++---- packages/channel/src/room.ts | 39 ++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/channel/src/channel.ts b/packages/channel/src/channel.ts index d92399ad..51a5936a 100644 --- a/packages/channel/src/channel.ts +++ b/packages/channel/src/channel.ts @@ -1,4 +1,10 @@ -import { getSupportedThinkingLevels, type Model, type Models } from "@earendil-works/pi-ai"; +import { + type AssistantMessage, + getSupportedThinkingLevels, + isContextOverflow, + type Model, + type Models, +} from "@earendil-works/pi-ai"; import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context"; import { AgentDoc, @@ -589,6 +595,10 @@ export class Channel { this.#author(request.author); const conversation = await this.#conversation(request.chat); room.checkAttachments(request); + // The chat's next run reads this message, so it must fit the model the chat has now. + const { model } = await conversation.agent(context); + const selected = model && this.#options.models.getModel(model.provider, model.modelId); + if (selected) room.checkContext(selected, request); const entry = room.draft(request.author, request.text, Date.now(), request); const submission = await conversation.submit({ type: "write", @@ -616,7 +626,7 @@ export class Channel { room.checkAttachments(request); await this.#mayInvoke(request.author); const conversation = await this.#conversation(request.chat); - await this.#select(conversation, request.model, request.effort); + await this.#select(conversation, request, request.model, request.effort); const submission = await conversation.submit({ type: "input", content: room.content(request.author, request.text, request), @@ -637,7 +647,12 @@ export class Channel { } /** Model and effort apply to a whole run, never just its later turns. */ - async #select(conversation: Conversation, requested: ModelRef | undefined, effort?: Effort) { + async #select( + conversation: Conversation, + attached: room.Attached, + requested: ModelRef | undefined, + effort?: Effort, + ) { const agent = await conversation.agent(context); const current = agent.model; // A chat's inherited model can lose its provider key; only an explicit request must keep it. @@ -651,6 +666,8 @@ export class Channel { if (!selected) { throw new Error(`Unknown model ${model.provider}/${model.modelId}`); } + // Before configuring, so a refused message leaves the chat's model as it was. + room.checkContext(selected, attached); const levels = getSupportedThinkingLevels(selected); if (effort !== undefined && effort !== "off" && !levels.includes(effort)) { throw new Error( @@ -1009,6 +1026,18 @@ function readable(error: string | undefined): string { } } +/** + * The transcript keeps whatever overflowed, so every later run in the chat fails the same way until + * a person removes it; channels from before attachments were checked against the model can hold one. + */ +function failed(message: AssistantMessage): string { + const error = readable(message.errorMessage); + if (!isContextOverflow(message)) return error; + const hint = "This chat no longer fits the model's context, so every run fails this way until " + + "you delete the message with the large attachment or pick a model with a larger context."; + return `${error.replace(/\.?$/, ".")} ${hint}`; +} + function live(chat: ChatId, event: AgentEvent): Event[] { if (event.type === "message_end" || event.type === "entry_appended") return events(event.entry); if (event.type === "message_update") { @@ -1047,7 +1076,7 @@ function events(entry: EntryRecord): Event[] { at, model, text, - ...(error ? { error: readable(message.errorMessage) } : {}), + ...(error ? { error: failed(message) } : {}), ...(stopped ? { stopped } : {}), }); } diff --git a/packages/channel/src/room.ts b/packages/channel/src/room.ts index 645fd0dd..975667b2 100644 --- a/packages/channel/src/room.ts +++ b/packages/channel/src/room.ts @@ -1,4 +1,11 @@ -import type { ImageContent, Message, TextContent, UserMessage } from "@earendil-works/pi-ai"; +import type { + Api, + ImageContent, + Message, + Model, + TextContent, + UserMessage, +} from "@earendil-works/pi-ai"; import { defineEntry, type EntryRecord, @@ -80,6 +87,36 @@ export function checkAttachments({ images = [], files = [] }: Attached) { if (size > MAX_BYTES) throw new Error("Attachments are too large; send smaller or fewer files"); } +/** + * Bytes, not characters: non-Latin text costs more tokens per character, and Keynote's + * multilingual license PDF measured about 2.8 bytes per token on Claude. + */ +const BYTES_PER_TOKEN = 3; + +/** + * A message whose files can't fit the model's context fails its run and every later one, since + * the transcript keeps it. The rest of the window holds the prompt, tools, history, and answer. + */ +export function checkContext(model: Model, { files = [] }: Attached) { + if (!files.length) return; + const encoder = new TextEncoder(); + const tokens = files.reduce( + (sum, file) => sum + Math.ceil(encoder.encode(file.text).length / BYTES_PER_TOKEN), + 0, + ); + const limit = Math.floor(model.contextWindow * 0.75); + if (tokens <= limit) return; + const names = files.map((file) => file.name).join(", "); + const verb = files.length === 1 ? "is" : "are"; + throw new Error( + `${names} ${verb} about ${thousands(tokens)} tokens; ${model.name} reads at most about ` + + `${thousands(limit)} tokens of attachments in one message. Attach a smaller file or an ` + + "excerpt, or pick a model with a larger context.", + ); +} + +const thousands = (tokens: number) => `${Math.round(tokens / 1000)}K`; + export function draft(author: string, text: string, timestamp: number, attached?: Attached) { return { kind: MessageEntry.kind, From 427a678a0d2a5c638b66545d64f4891794307275 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Fri, 16 Oct 2026 19:12:54 -0400 Subject: [PATCH 2/3] lane: sync attachment-context memory --- ...XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md | 6 +++--- ...4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md | 6 +++--- ...4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md | 6 +++--- ...ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md | 6 +++--- ...3CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md | 6 +++--- ...3FTPSETKJ5KVVERXS3KAHK-overflow-errors-are-explaine.md | 8 ++++++++ ...3FTPRW0WRSS93WVMFYZBKE-estimate-from-utf-8-bytes-no.md | 8 ++++++++ 7 files changed, 31 insertions(+), 15 deletions(-) create mode 100644 .lane/memory/packages/channel/src/channel.ts/01M53FTPSETKJ5KVVERXS3KAHK-overflow-errors-are-explaine.md create mode 100644 .lane/memory/packages/channel/src/room.ts/01M53FTPRW0WRSS93WVMFYZBKE-estimate-from-utf-8-bytes-no.md diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md index e854f3e3..496dfd2a 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-14T17:33:22Z norm: '1' sig: c905677fa4be33c1 -body_hash: 3b6eafcbe8faee26 -raw_hash: 67f0e882288fa8ce -vouched: 2026-10-16T22:27:53Z +body_hash: 3d7816658a739ed7 +raw_hash: 85f3d4da91b05dae +vouched: 2026-10-16T23:12:54Z lines: 114-792 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md index 506850d6..5de3478a 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T00:30:42Z norm: '1' sig: c905677fa4be33c1 -body_hash: 3b6eafcbe8faee26 -raw_hash: 67f0e882288fa8ce -vouched: 2026-10-16T22:27:53Z +body_hash: 3d7816658a739ed7 +raw_hash: 85f3d4da91b05dae +vouched: 2026-10-16T23:12:54Z lines: 114-792 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md index 2699b48b..fe661f60 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T02:34:37Z norm: '1' sig: c905677fa4be33c1 -body_hash: 3b6eafcbe8faee26 -raw_hash: 67f0e882288fa8ce -vouched: 2026-10-16T22:27:53Z +body_hash: 3d7816658a739ed7 +raw_hash: 85f3d4da91b05dae +vouched: 2026-10-16T23:12:54Z lines: 114-794 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md index 941c7144..09e1a7c5 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T10:17:55Z norm: '1' sig: c905677fa4be33c1 -body_hash: 3b6eafcbe8faee26 -raw_hash: 67f0e882288fa8ce -vouched: 2026-10-16T22:27:53Z +body_hash: 3d7816658a739ed7 +raw_hash: 85f3d4da91b05dae +vouched: 2026-10-16T23:12:54Z lines: 121-817 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md b/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md index 9bd19728..37d938e4 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-16T22:11:35Z norm: '1' sig: c905677fa4be33c1 -body_hash: 3b6eafcbe8faee26 -raw_hash: 67f0e882288fa8ce -vouched: 2026-10-16T22:27:53Z +body_hash: 3d7816658a739ed7 +raw_hash: 85f3d4da91b05dae +vouched: 2026-10-16T23:12:54Z lines: 127-873 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M53FTPSETKJ5KVVERXS3KAHK-overflow-errors-are-explaine.md b/.lane/memory/packages/channel/src/channel.ts/01M53FTPSETKJ5KVVERXS3KAHK-overflow-errors-are-explaine.md new file mode 100644 index 00000000..50d7b61a --- /dev/null +++ b/.lane/memory/packages/channel/src/channel.ts/01M53FTPSETKJ5KVVERXS3KAHK-overflow-errors-are-explaine.md @@ -0,0 +1,8 @@ +--- +id: 01M53FTPSETKJ5KVVERXS3KAHK +anchor: function failed +created: 2026-10-16T23:12:51Z +norm: '1' +--- + +overflow errors are explained in the projection, not stored, so replies already stored by older channels get the recovery hint too diff --git a/.lane/memory/packages/channel/src/room.ts/01M53FTPRW0WRSS93WVMFYZBKE-estimate-from-utf-8-bytes-no.md b/.lane/memory/packages/channel/src/room.ts/01M53FTPRW0WRSS93WVMFYZBKE-estimate-from-utf-8-bytes-no.md new file mode 100644 index 00000000..44ef2b42 --- /dev/null +++ b/.lane/memory/packages/channel/src/room.ts/01M53FTPRW0WRSS93WVMFYZBKE-estimate-from-utf-8-bytes-no.md @@ -0,0 +1,8 @@ +--- +id: 01M53FTPRW0WRSS93WVMFYZBKE +anchor: function checkContext +created: 2026-10-16T23:12:51Z +norm: '1' +--- + +estimate from UTF-8 bytes, not chars; pi-ai's chars/4 undercounts non-Latin text by ~2x, which is how multilingual PDFs overflowed From 1152e8fa50239098ad4bb278fe2a65734bb1de69 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Fri, 16 Oct 2026 19:13:09 -0400 Subject: [PATCH 3/3] lane: sync attachment-context memory --- ...01M53FTPSETKJ5KVVERXS3KAHK-overflow-errors-are-explaine.md | 4 ++++ ...01M53FTPRW0WRSS93WVMFYZBKE-estimate-from-utf-8-bytes-no.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.lane/memory/packages/channel/src/channel.ts/01M53FTPSETKJ5KVVERXS3KAHK-overflow-errors-are-explaine.md b/.lane/memory/packages/channel/src/channel.ts/01M53FTPSETKJ5KVVERXS3KAHK-overflow-errors-are-explaine.md index 50d7b61a..9d6e333d 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M53FTPSETKJ5KVVERXS3KAHK-overflow-errors-are-explaine.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M53FTPSETKJ5KVVERXS3KAHK-overflow-errors-are-explaine.md @@ -3,6 +3,10 @@ id: 01M53FTPSETKJ5KVVERXS3KAHK anchor: function failed created: 2026-10-16T23:12:51Z norm: '1' +sig: ef0729315ff1212a +body_hash: d35c24211c9f9acd +raw_hash: a835bc42c69d89ed +lines: 1033-1039 --- overflow errors are explained in the projection, not stored, so replies already stored by older channels get the recovery hint too diff --git a/.lane/memory/packages/channel/src/room.ts/01M53FTPRW0WRSS93WVMFYZBKE-estimate-from-utf-8-bytes-no.md b/.lane/memory/packages/channel/src/room.ts/01M53FTPRW0WRSS93WVMFYZBKE-estimate-from-utf-8-bytes-no.md index 44ef2b42..606a30b2 100644 --- a/.lane/memory/packages/channel/src/room.ts/01M53FTPRW0WRSS93WVMFYZBKE-estimate-from-utf-8-bytes-no.md +++ b/.lane/memory/packages/channel/src/room.ts/01M53FTPRW0WRSS93WVMFYZBKE-estimate-from-utf-8-bytes-no.md @@ -3,6 +3,10 @@ id: 01M53FTPRW0WRSS93WVMFYZBKE anchor: function checkContext created: 2026-10-16T23:12:51Z norm: '1' +sig: 9bf4c87f920895b7 +body_hash: 393fd86707463e3c +raw_hash: 338145fbeb8c2ee8 +lines: 100-116 --- estimate from UTF-8 bytes, not chars; pi-ai's chars/4 undercounts non-Latin text by ~2x, which is how multilingual PDFs overflowed