@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.
- package/dist/attachment-payload.d.ts +95 -0
- package/dist/attachment-payload.d.ts.map +1 -0
- package/dist/attachment-payload.js +155 -0
- package/dist/attachment-payload.js.map +1 -0
- package/dist/events.d.ts +6 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mode/project-messages.d.ts +3 -1
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +115 -16
- package/dist/mode/project-messages.js.map +1 -1
- package/package.json +5 -1
- package/src/attachment-payload.test.ts +165 -0
- package/src/attachment-payload.ts +187 -0
- package/src/events.ts +6 -0
- package/src/index.ts +19 -0
- package/src/mode/project-messages.test.ts +107 -1
- package/src/mode/project-messages.ts +129 -17
|
@@ -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(
|
|
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.
|
|
156
|
-
blocks.push({
|
|
157
|
-
|
|
158
|
-
|
|
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({
|
|
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;
|