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..9d6e333d --- /dev/null +++ b/.lane/memory/packages/channel/src/channel.ts/01M53FTPSETKJ5KVVERXS3KAHK-overflow-errors-are-explaine.md @@ -0,0 +1,12 @@ +--- +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 new file mode 100644 index 00000000..606a30b2 --- /dev/null +++ b/.lane/memory/packages/channel/src/room.ts/01M53FTPRW0WRSS93WVMFYZBKE-estimate-from-utf-8-bytes-no.md @@ -0,0 +1,12 @@ +--- +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 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,