@moxxy/sdk 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,13 @@ import type { ContentBlock, ProviderMessage } from '../provider.js';
2
2
  import type { ModeContext } from '../mode.js';
3
3
  import type { Skill } from '../skill.js';
4
4
  import { toolPatternMatches } from '../skill-mentions.js';
5
- import type { MoxxyEvent, UserPromptEvent } from '../events.js';
5
+ import type { MoxxyEvent, UserPromptAttachment, UserPromptEvent } from '../events.js';
6
+ import {
7
+ MODEL_ATTACHMENT_BUDGET_CHARS,
8
+ attachmentBytes,
9
+ attachmentKey,
10
+ restoredAttachmentRef,
11
+ } from '../attachment-payload.js';
6
12
  import {
7
13
  activeCompactionRanges,
8
14
  makeCompactionLookup,
@@ -135,6 +141,96 @@ export interface ProjectMessagesOptions {
135
141
  readonly precomputedElisionState?: ElisionState;
136
142
  }
137
143
 
144
+ function hasModelPayload(att: UserPromptAttachment): boolean {
145
+ return att.kind === 'image' || att.kind === 'document';
146
+ }
147
+
148
+ function attachmentLeftOutNote(att: UserPromptAttachment, seq: number, index: number): string {
149
+ const label = `${att.kind}${att.name ? ` "${att.name}"` : ''}`;
150
+ const mb = (attachmentBytes(att) / (1024 * 1024)).toFixed(1);
151
+ return (
152
+ `[${label} (${mb} MB) was attached earlier in this conversation and is no longer sent ` +
153
+ `with each request, to keep the request within the model's size limit. ` +
154
+ `To see it again, call recall({ attachment: "${attachmentKey(seq, index)}" }).]`
155
+ );
156
+ }
157
+
158
+ /** The image or document block an attachment the model reads as bytes becomes. */
159
+ function attachmentBlock(att: UserPromptAttachment): ContentBlock {
160
+ if (att.kind === 'image') {
161
+ return { type: 'image', mediaType: att.mediaType ?? 'image/png', data: att.content };
162
+ }
163
+ return {
164
+ type: 'document',
165
+ mediaType: att.mediaType ?? 'application/pdf',
166
+ data: att.content,
167
+ ...(att.name ? { name: att.name } : {}),
168
+ };
169
+ }
170
+
171
+ /** The prompts that carry attachments, by seq, so a restoring recall finds its original. */
172
+ function attachmentPromptsBySeq(events: ReadonlyArray<MoxxyEvent>): ReadonlyMap<number, UserPromptEvent> {
173
+ const prompts = new Map<number, UserPromptEvent>();
174
+ for (const e of events) {
175
+ if (e.type === 'user_prompt' && e.attachments && e.attachments.length > 0) prompts.set(e.seq, e);
176
+ }
177
+ return prompts;
178
+ }
179
+
180
+ /** The image/document a `recall({ attachment })` result brings back, or null. */
181
+ function restoredAttachment(
182
+ e: Extract<MoxxyEvent, { type: 'tool_result' }>,
183
+ prompts: ReadonlyMap<number, UserPromptEvent>,
184
+ ): UserPromptAttachment | null {
185
+ if (!e.ok || e.error) return null;
186
+ const ref = restoredAttachmentRef(e.output);
187
+ if (!ref) return null;
188
+ const prompt = prompts.get(ref.seq);
189
+ const att = prompt ? prompt.attachments?.[ref.index] : undefined;
190
+ return att && hasModelPayload(att) && att.omittedBytes === undefined ? att : null;
191
+ }
192
+
193
+ /** The budget key of what a restoring recall result shows. */
194
+ const restoreKey = (seq: number): string => `r:${seq}`;
195
+
196
+ /**
197
+ * Keys of the image/document payloads that go to the model in full: an
198
+ * attachment's `seq:index`, or `r:seq` for one a recall result restores.
199
+ * Newest first, while they fit {@link MODEL_ATTACHMENT_BUDGET_CHARS}, so a
200
+ * restored attachment takes its place among the newest. Deterministic for a
201
+ * given log, so the prompt cache sees a stable prefix.
202
+ */
203
+ function attachmentsSentInFull(
204
+ events: ReadonlyArray<MoxxyEvent>,
205
+ el: ElisionState,
206
+ isCompacted: (seq: number) => boolean,
207
+ prompts: ReadonlyMap<number, UserPromptEvent>,
208
+ ): ReadonlySet<string> {
209
+ const keys = new Set<string>();
210
+ let room = MODEL_ATTACHMENT_BUDGET_CHARS;
211
+ const take = (key: string, att: UserPromptAttachment): void => {
212
+ if (att.content.length > room) return;
213
+ room -= att.content.length;
214
+ keys.add(key);
215
+ };
216
+ for (let i = events.length - 1; i >= 0; i -= 1) {
217
+ const e = events[i];
218
+ if (!e || isCompacted(e.seq)) continue;
219
+ if (e.type === 'tool_result') {
220
+ if (toolResultStubbed(e, el)) continue;
221
+ const att = restoredAttachment(e, prompts);
222
+ if (att) take(restoreKey(e.seq), att);
223
+ continue;
224
+ }
225
+ if (e.type !== 'user_prompt' || !e.attachments || conversationalStubbed(e, el)) continue;
226
+ for (const [index, att] of e.attachments.entries()) {
227
+ if (!hasModelPayload(att) || att.omittedBytes !== undefined) continue;
228
+ take(attachmentKey(e.seq, index), att);
229
+ }
230
+ }
231
+ return keys;
232
+ }
233
+
138
234
  /**
139
235
  * Pure projection of a single `user_prompt` event to its content blocks.
140
236
  *
@@ -144,27 +240,23 @@ export interface ProjectMessagesOptions {
144
240
  * what the inline switch arm produced. Caller decides how to wrap it as a
145
241
  * message / record the stable prefix.
146
242
  */
147
- export function projectUserPrompt(event: UserPromptEvent, el: ElisionState): ContentBlock[] {
243
+ export function projectUserPrompt(
244
+ event: UserPromptEvent,
245
+ el: ElisionState,
246
+ /** Which attachments go to the model in full; the rest become a short note. Default: all. */
247
+ sendsInFull: (index: number) => boolean = () => true,
248
+ ): ContentBlock[] {
148
249
  // Elided + conversational: collapse to a stub (anchor/tiny kept full).
149
250
  if (conversationalStubbed(event, el)) {
150
251
  return [{ type: 'text', text: conversationalStub('user', event.seq) }];
151
252
  }
152
253
  const blocks: ContentBlock[] = [{ type: 'text', text: event.text }];
153
254
  if (event.attachments) {
154
- for (const att of event.attachments) {
155
- if (att.kind === 'image') {
156
- blocks.push({
157
- type: 'image',
158
- mediaType: att.mediaType ?? 'image/png',
159
- data: att.content,
160
- });
161
- } else if (att.kind === 'document') {
162
- blocks.push({
163
- type: 'document',
164
- mediaType: att.mediaType ?? 'application/pdf',
165
- data: att.content,
166
- ...(att.name ? { name: att.name } : {}),
167
- });
255
+ for (const [index, att] of event.attachments.entries()) {
256
+ if (att.omittedBytes !== undefined || (hasModelPayload(att) && !sendsInFull(index))) {
257
+ blocks.push({ type: 'text', text: attachmentLeftOutNote(att, event.seq, index) });
258
+ } else if (hasModelPayload(att)) {
259
+ blocks.push(attachmentBlock(att));
168
260
  } else {
169
261
  blocks.push({
170
262
  type: 'text',
@@ -277,6 +369,8 @@ export function projectMessages(
277
369
  // Only VISIBLE results count: one swallowed by a compaction is never sent, so
278
370
  // its still-visible tool_use needs the fallback just like a missing one.
279
371
  const resolvedCallIds = resolvedCallIdSet(allEvents.filter((e) => !compactionFor(e.seq)));
372
+ const attachmentPrompts = attachmentPromptsBySeq(allEvents);
373
+ const sentInFull = attachmentsSentInFull(allEvents, el, (seq) => compactionFor(seq) !== null, attachmentPrompts);
280
374
  // callIds whose tool_use block is projected. A tool_result outside this set
281
375
  // answers a call a compaction summarized away (the result landed after later
282
376
  // turns began); providers reject a result with no matching call.
@@ -347,7 +441,10 @@ export function projectMessages(
347
441
  case 'user_prompt': {
348
442
  flush();
349
443
  pendingReasoning = null;
350
- messages.push({ role: 'user', content: projectUserPrompt(e, el) });
444
+ messages.push({
445
+ role: 'user',
446
+ content: projectUserPrompt(e, el, (index) => sentInFull.has(`${e.seq}:${index}`)),
447
+ });
351
448
  recordStable(e.seq);
352
449
  break;
353
450
  }
@@ -431,6 +528,10 @@ export function projectMessages(
431
528
  // provider `image` block so the model SEES the pixels — only when the
432
529
  // result isn't stubbed (elided) or an error and the call succeeded.
433
530
  let image: Extract<ContentBlock, { type: 'image' }> | null = null;
531
+ // A `recall({ attachment })` result: the original image/document goes
532
+ // beside it, from the prompt that carried it, while the budget allows.
533
+ let restored: ContentBlock | null = null;
534
+ const restoredAtt = toolResultStubbed(e, el) ? null : restoredAttachment(e, attachmentPrompts);
434
535
  if (toolResultStubbed(e, el)) {
435
536
  text = toolResultStubText(e, el);
436
537
  } else if (e.error) {
@@ -439,6 +540,16 @@ export function projectMessages(
439
540
  // Rich result (e.g. a file diff): the model only needs the short
440
541
  // `forModel` summary — the structured `display` is for channels.
441
542
  text = e.output.forModel;
543
+ } else if (restoredAtt) {
544
+ const description = (e.output as { forModel?: unknown }).forModel;
545
+ text = typeof description === 'string' ? description : '[attachment restored — see below]';
546
+ if (sentInFull.has(restoreKey(e.seq))) {
547
+ restored = attachmentBlock(restoredAtt);
548
+ } else {
549
+ text +=
550
+ ' [It could not be shown again: newer attachments already fill what one request may carry.' +
551
+ ' Ask the person to attach it again if you need it now.]';
552
+ }
442
553
  } else if (e.ok && (image = imageBlockFromOutput(e.output))) {
443
554
  // Short marker satisfies the tool_use→tool_result pairing; the image
444
555
  // block (appended below) carries the actual pixels.
@@ -453,6 +564,7 @@ export function projectMessages(
453
564
  { type: 'tool_result', toolUseId: e.callId, content: text, isError: !e.ok },
454
565
  ];
455
566
  if (image) content.push(image);
567
+ if (restored) content.push(restored);
456
568
  messages.push({ role: 'tool_result', content });
457
569
  recordStable(e.seq);
458
570
  break;