@moxxy/sdk 0.43.0 → 0.45.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.
Files changed (246) hide show
  1. package/dist/agent-account-limit.d.ts +41 -0
  2. package/dist/agent-account-limit.d.ts.map +1 -0
  3. package/dist/agent-account-limit.js +64 -0
  4. package/dist/agent-account-limit.js.map +1 -0
  5. package/dist/agent-computer-use.d.ts +10 -0
  6. package/dist/agent-computer-use.d.ts.map +1 -0
  7. package/dist/agent-computer-use.js +24 -0
  8. package/dist/agent-computer-use.js.map +1 -0
  9. package/dist/agent-context-window.d.ts +22 -0
  10. package/dist/agent-context-window.d.ts.map +1 -0
  11. package/dist/agent-context-window.js +40 -0
  12. package/dist/agent-context-window.js.map +1 -0
  13. package/dist/agent-no-reply.d.ts +11 -0
  14. package/dist/agent-no-reply.d.ts.map +1 -0
  15. package/dist/agent-no-reply.js +13 -0
  16. package/dist/agent-no-reply.js.map +1 -0
  17. package/dist/agent-step.d.ts +14 -0
  18. package/dist/agent-step.d.ts.map +1 -0
  19. package/dist/agent-step.js +26 -0
  20. package/dist/agent-step.js.map +1 -0
  21. package/dist/agent-work.d.ts +34 -0
  22. package/dist/agent-work.d.ts.map +1 -0
  23. package/dist/agent-work.js +80 -0
  24. package/dist/agent-work.js.map +1 -0
  25. package/dist/attachment-payload.d.ts +95 -0
  26. package/dist/attachment-payload.d.ts.map +1 -0
  27. package/dist/attachment-payload.js +155 -0
  28. package/dist/attachment-payload.js.map +1 -0
  29. package/dist/auto-approve.d.ts +9 -0
  30. package/dist/auto-approve.d.ts.map +1 -1
  31. package/dist/auto-approve.js +18 -0
  32. package/dist/auto-approve.js.map +1 -1
  33. package/dist/channel-auth.d.ts +3 -3
  34. package/dist/channel-auth.d.ts.map +1 -1
  35. package/dist/channel-auth.js +1 -1
  36. package/dist/channel.d.ts +1 -1
  37. package/dist/client-session.d.ts +9 -5
  38. package/dist/client-session.d.ts.map +1 -1
  39. package/dist/computer-control.d.ts +11 -11
  40. package/dist/computer-control.d.ts.map +1 -1
  41. package/dist/computer-control.js +1 -1
  42. package/dist/computer-control.js.map +1 -1
  43. package/dist/conversation-history.d.ts +50 -0
  44. package/dist/conversation-history.d.ts.map +1 -0
  45. package/dist/conversation-history.js +227 -0
  46. package/dist/conversation-history.js.map +1 -0
  47. package/dist/cron.d.ts +58 -0
  48. package/dist/cron.d.ts.map +1 -0
  49. package/dist/cron.js +320 -0
  50. package/dist/cron.js.map +1 -0
  51. package/dist/define.d.ts +2 -1
  52. package/dist/define.d.ts.map +1 -1
  53. package/dist/define.js +1 -0
  54. package/dist/define.js.map +1 -1
  55. package/dist/event-store.d.ts +1 -1
  56. package/dist/event-store.d.ts.map +1 -1
  57. package/dist/event-store.js +1 -0
  58. package/dist/event-store.js.map +1 -1
  59. package/dist/events.d.ts +23 -1
  60. package/dist/events.d.ts.map +1 -1
  61. package/dist/file-diff.d.ts +31 -0
  62. package/dist/file-diff.d.ts.map +1 -0
  63. package/dist/file-diff.js +196 -0
  64. package/dist/file-diff.js.map +1 -0
  65. package/dist/index.d.ts +23 -7
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +17 -4
  68. package/dist/index.js.map +1 -1
  69. package/dist/mode/agent-step-loop.d.ts +13 -0
  70. package/dist/mode/agent-step-loop.d.ts.map +1 -0
  71. package/dist/mode/agent-step-loop.js +578 -0
  72. package/dist/mode/agent-step-loop.js.map +1 -0
  73. package/dist/mode/checkpoint-gate.d.ts +25 -0
  74. package/dist/mode/checkpoint-gate.d.ts.map +1 -0
  75. package/dist/mode/checkpoint-gate.js +150 -0
  76. package/dist/mode/checkpoint-gate.js.map +1 -0
  77. package/dist/mode/checkpoint.d.ts +6 -0
  78. package/dist/mode/checkpoint.d.ts.map +1 -1
  79. package/dist/mode/collect-stream.d.ts +2 -0
  80. package/dist/mode/collect-stream.d.ts.map +1 -1
  81. package/dist/mode/collect-stream.js +1 -1
  82. package/dist/mode/collect-stream.js.map +1 -1
  83. package/dist/mode/project-messages.d.ts +3 -1
  84. package/dist/mode/project-messages.d.ts.map +1 -1
  85. package/dist/mode/project-messages.js +122 -81
  86. package/dist/mode/project-messages.js.map +1 -1
  87. package/dist/mode/react-loop-options.d.ts +113 -0
  88. package/dist/mode/react-loop-options.d.ts.map +1 -0
  89. package/dist/mode/react-loop-options.js +2 -0
  90. package/dist/mode/react-loop-options.js.map +1 -0
  91. package/dist/mode/react-loop.d.ts +2 -99
  92. package/dist/mode/react-loop.d.ts.map +1 -1
  93. package/dist/mode/react-loop.js +7 -145
  94. package/dist/mode/react-loop.js.map +1 -1
  95. package/dist/mode.d.ts +19 -7
  96. package/dist/mode.d.ts.map +1 -1
  97. package/dist/mode.js +1 -1
  98. package/dist/mode.js.map +1 -1
  99. package/dist/npm-registry.d.ts +22 -0
  100. package/dist/npm-registry.d.ts.map +1 -0
  101. package/dist/npm-registry.js +54 -0
  102. package/dist/npm-registry.js.map +1 -0
  103. package/dist/permission.d.ts +31 -0
  104. package/dist/permission.d.ts.map +1 -1
  105. package/dist/process-invocation.d.ts +2 -0
  106. package/dist/process-invocation.d.ts.map +1 -1
  107. package/dist/process-invocation.js +2 -2
  108. package/dist/process-invocation.js.map +1 -1
  109. package/dist/process-tree.d.ts +9 -0
  110. package/dist/process-tree.d.ts.map +1 -0
  111. package/dist/process-tree.js +40 -0
  112. package/dist/process-tree.js.map +1 -0
  113. package/dist/provider-login-bridge.js +1 -1
  114. package/dist/provider-login-bridge.js.map +1 -1
  115. package/dist/provider.d.ts +408 -0
  116. package/dist/provider.d.ts.map +1 -1
  117. package/dist/question-draft.d.ts +19 -0
  118. package/dist/question-draft.d.ts.map +1 -0
  119. package/dist/question-draft.js +30 -0
  120. package/dist/question-draft.js.map +1 -0
  121. package/dist/question.d.ts +204 -0
  122. package/dist/question.d.ts.map +1 -0
  123. package/dist/question.js +141 -0
  124. package/dist/question.js.map +1 -0
  125. package/dist/questions.d.ts +7 -0
  126. package/dist/questions.d.ts.map +1 -0
  127. package/dist/questions.js +7 -0
  128. package/dist/questions.js.map +1 -0
  129. package/dist/resolvers.d.ts +14 -1
  130. package/dist/resolvers.d.ts.map +1 -1
  131. package/dist/resolvers.js +33 -21
  132. package/dist/resolvers.js.map +1 -1
  133. package/dist/schemas.d.ts +53 -53
  134. package/dist/schemas.d.ts.map +1 -1
  135. package/dist/schemas.js +3 -2
  136. package/dist/schemas.js.map +1 -1
  137. package/dist/server.d.ts +2 -0
  138. package/dist/server.d.ts.map +1 -1
  139. package/dist/server.js +3 -0
  140. package/dist/server.js.map +1 -1
  141. package/dist/session-like.d.ts +19 -1
  142. package/dist/session-like.d.ts.map +1 -1
  143. package/dist/starting-model.d.ts +12 -0
  144. package/dist/starting-model.d.ts.map +1 -0
  145. package/dist/starting-model.js +9 -0
  146. package/dist/starting-model.js.map +1 -0
  147. package/dist/tool-dispatch.d.ts.map +1 -1
  148. package/dist/tool-dispatch.js +10 -4
  149. package/dist/tool-dispatch.js.map +1 -1
  150. package/dist/tool-display.d.ts +79 -3
  151. package/dist/tool-display.d.ts.map +1 -1
  152. package/dist/tool-display.js +117 -3
  153. package/dist/tool-display.js.map +1 -1
  154. package/dist/tool-modes.d.ts +1 -1
  155. package/dist/tool-modes.d.ts.map +1 -1
  156. package/dist/tool-modes.js +8 -2
  157. package/dist/tool-modes.js.map +1 -1
  158. package/dist/tool-output.d.ts +15 -0
  159. package/dist/tool-output.d.ts.map +1 -0
  160. package/dist/tool-output.js +39 -0
  161. package/dist/tool-output.js.map +1 -0
  162. package/dist/tool.d.ts +14 -1
  163. package/dist/tool.d.ts.map +1 -1
  164. package/dist/tunnel.d.ts +3 -3
  165. package/dist/tunnel.d.ts.map +1 -1
  166. package/dist/tunnel.js +2 -2
  167. package/dist/workflow-approval.d.ts +9 -9
  168. package/dist/workflow-approval.d.ts.map +1 -1
  169. package/dist/workflow-approval.js +1 -1
  170. package/dist/workflow-approval.js.map +1 -1
  171. package/package.json +37 -5
  172. package/src/agent-account-limit.test.ts +87 -0
  173. package/src/agent-account-limit.ts +89 -0
  174. package/src/agent-computer-use.test.ts +26 -0
  175. package/src/agent-computer-use.ts +29 -0
  176. package/src/agent-context-window.ts +51 -0
  177. package/src/agent-no-reply.ts +16 -0
  178. package/src/agent-step.ts +26 -0
  179. package/src/agent-work.ts +102 -0
  180. package/src/attachment-payload.test.ts +165 -0
  181. package/src/attachment-payload.ts +187 -0
  182. package/src/auto-approve.test.ts +13 -1
  183. package/src/auto-approve.ts +19 -0
  184. package/src/channel-auth.ts +3 -3
  185. package/src/channel.ts +1 -1
  186. package/src/client-session.ts +9 -2
  187. package/src/computer-control.ts +1 -1
  188. package/src/conversation-history.test.ts +184 -0
  189. package/src/conversation-history.ts +272 -0
  190. package/src/cron.test.ts +280 -0
  191. package/src/cron.ts +355 -0
  192. package/src/define.test.ts +1 -1
  193. package/src/define.ts +3 -1
  194. package/src/event-store.ts +2 -1
  195. package/src/events.ts +23 -1
  196. package/src/file-diff.test.ts +103 -0
  197. package/src/file-diff.ts +208 -0
  198. package/src/index.ts +140 -1
  199. package/src/json-file-store.test.ts +1 -1
  200. package/src/loop-helpers.test.ts +15 -0
  201. package/src/mode/agent-step-loop.ts +645 -0
  202. package/src/mode/checkpoint-gate.ts +198 -0
  203. package/src/mode/checkpoint.ts +6 -0
  204. package/src/mode/collect-stream.ts +1 -1
  205. package/src/mode/project-messages.test.ts +118 -1
  206. package/src/mode/project-messages.ts +136 -77
  207. package/src/mode/react-loop-options.ts +124 -0
  208. package/src/mode/react-loop.ts +10 -296
  209. package/src/mode.ts +19 -7
  210. package/src/npm-registry.test.ts +95 -0
  211. package/src/npm-registry.ts +57 -0
  212. package/src/permission.ts +30 -0
  213. package/src/process-invocation.test.ts +25 -0
  214. package/src/process-invocation.ts +8 -3
  215. package/src/process-tree.ts +37 -0
  216. package/src/provider-login-bridge.ts +1 -1
  217. package/src/provider-schema-constraints.test.ts +1 -1
  218. package/src/provider-utils.test.ts +1 -1
  219. package/src/provider.ts +346 -0
  220. package/src/question-draft.test.ts +57 -0
  221. package/src/question-draft.ts +47 -0
  222. package/src/question.test.ts +130 -0
  223. package/src/question.ts +229 -0
  224. package/src/questions.ts +6 -0
  225. package/src/resolvers.test.ts +84 -1
  226. package/src/resolvers.ts +40 -27
  227. package/src/schemas.test.ts +5 -0
  228. package/src/schemas.ts +3 -2
  229. package/src/server.ts +3 -0
  230. package/src/session-like.ts +19 -1
  231. package/src/skill-mentions.test.ts +1 -1
  232. package/src/starting-model.test.ts +19 -0
  233. package/src/starting-model.ts +11 -0
  234. package/src/token-efficiency.test.ts +1 -1
  235. package/src/tool-dispatch.ts +10 -4
  236. package/src/tool-display.test.ts +119 -0
  237. package/src/tool-display.ts +162 -4
  238. package/src/tool-gating.test.ts +1 -1
  239. package/src/tool-icon.test.ts +1 -1
  240. package/src/tool-modes.test.ts +18 -1
  241. package/src/tool-modes.ts +6 -2
  242. package/src/tool-output.test.ts +46 -0
  243. package/src/tool-output.ts +46 -0
  244. package/src/tool.ts +14 -1
  245. package/src/tunnel.ts +3 -3
  246. package/src/workflow-approval.ts +1 -1
@@ -0,0 +1,198 @@
1
+ import { asPluginId } from '../ids.js';
2
+ import type { MoxxyEvent } from '../events.js';
3
+ import type { ModeContext } from '../mode.js';
4
+ import type { StopReason } from '../provider-utils.js';
5
+ import type { CheckpointResult, TurnCheckpoint } from './checkpoint.js';
6
+
7
+ /**
8
+ * The turn-end checkpoint gate and the loop's own lifecycle events, shared by
9
+ * the ReAct loop and the agent-connection loop.
10
+ */
11
+
12
+ const DEFAULT_CHECKPOINT_TIMEOUT_MS = 120_000;
13
+ const CHECKPOINT_EVENT_PLUGIN_ID = asPluginId('react-loop');
14
+
15
+ /** Gate outcome: end the turn, or loop again (optionally with a volatile nudge). */
16
+ export type GateVerdict = { readonly kind: 'end' } | { readonly kind: 'loop'; readonly volatileText?: string };
17
+
18
+ export interface GateRound {
19
+ readonly candidateText: string;
20
+ readonly stopReason: StopReason;
21
+ readonly iteration: number;
22
+ readonly consecutiveIdle: number;
23
+ readonly injectionsUsed: number;
24
+ readonly injectionBudget: number;
25
+ readonly maxInjectChars: number;
26
+ }
27
+
28
+ export async function* runCheckpointGate(
29
+ ctx: ModeContext,
30
+ checkpoints: ReadonlyArray<TurnCheckpoint>,
31
+ round: GateRound,
32
+ ): AsyncGenerator<MoxxyEvent, GateVerdict> {
33
+ // Natural completions face every checkpoint; truncated/errored candidates
34
+ // only face the idle-tolerant ones — reviewing a half-sentence as if it
35
+ // were a completion claim wastes a checker run and confuses the model.
36
+ const eligible = checkpoints.filter(
37
+ (cp) =>
38
+ (round.stopReason === 'end_turn' || (cp.gateOn ?? 'end_turn') === 'idle') && (cp.applies?.(ctx) ?? true),
39
+ );
40
+ if (eligible.length === 0) return { kind: 'end' };
41
+
42
+ // Budget exhausted → the answer ships as-is, but LOUDLY: silent
43
+ // degradation would let the user believe a gated answer passed its gates.
44
+ if (round.injectionsUsed >= round.injectionBudget) {
45
+ yield await emitError(
46
+ ctx,
47
+ 'retryable',
48
+ `checkpoint budget exhausted (${round.injectionBudget} rounds) — ` +
49
+ `ending the turn with unresolved checkpoint feedback; verify the result manually.`,
50
+ );
51
+ return { kind: 'end' };
52
+ }
53
+
54
+ for (const cp of eligible) {
55
+ // User cancelled mid-gate: end the turn WITHOUT retracting the
56
+ // already-logged answer — an abort must never un-say what was said.
57
+ if (ctx.signal.aborted) return { kind: 'end' };
58
+
59
+ yield await emitCheckpointEvent(ctx, 'checkpoint_started', {
60
+ name: cp.name,
61
+ iteration: round.iteration,
62
+ });
63
+
64
+ const timeoutMs = Math.max(1_000, cp.timeoutMs ?? DEFAULT_CHECKPOINT_TIMEOUT_MS);
65
+ const timeoutCtl = new AbortController();
66
+ const timer = setTimeout(() => timeoutCtl.abort(), timeoutMs);
67
+ timer.unref?.();
68
+ let result: CheckpointResult;
69
+ try {
70
+ result = await cp.run(
71
+ {
72
+ candidateText: round.candidateText,
73
+ stopReason: round.stopReason,
74
+ iteration: round.iteration,
75
+ consecutiveIdle: round.consecutiveIdle,
76
+ injectionsUsed: round.injectionsUsed,
77
+ injectionBudget: round.injectionBudget,
78
+ signal: AbortSignal.any([ctx.signal, timeoutCtl.signal]),
79
+ },
80
+ ctx,
81
+ );
82
+ } catch (err) {
83
+ if (ctx.signal.aborted) return { kind: 'end' };
84
+ // Fail OPEN, visibly. A crashed or timed-out checker downgrades the
85
+ // gate to a warning — it must never wedge the turn (fail-closed is a
86
+ // turn that can never end) and never crash it (the answer is already
87
+ // logged).
88
+ const why = timeoutCtl.signal.aborted
89
+ ? `timed out after ${timeoutMs}ms`
90
+ : `failed: ${err instanceof Error ? err.message : String(err)}`;
91
+ yield await emitError(ctx, 'retryable', `checkpoint "${cp.name}" ${why} — proceeding unchecked`);
92
+ continue;
93
+ } finally {
94
+ clearTimeout(timer);
95
+ }
96
+
97
+ switch (result.action) {
98
+ case 'pass': {
99
+ yield await emitCheckpointEvent(ctx, 'checkpoint_passed', { name: cp.name });
100
+ continue;
101
+ }
102
+ case 'stop': {
103
+ // The checkpoint already emitted its own wrap-up events.
104
+ yield await emitCheckpointEvent(ctx, 'checkpoint_stopped', { name: cp.name });
105
+ return { kind: 'end' };
106
+ }
107
+ case 'retry': {
108
+ yield await emitCheckpointEvent(ctx, 'checkpoint_retry', { name: cp.name });
109
+ return { kind: 'loop' };
110
+ }
111
+ case 'inject': {
112
+ // Guard the guard: an inject with blank text would loop the turn
113
+ // with no new signal for the model — a checker bug; fail open.
114
+ const feedback = result.text?.trim();
115
+ if (!feedback) {
116
+ yield await emitError(
117
+ ctx,
118
+ 'retryable',
119
+ `checkpoint "${cp.name}" injected empty feedback — ignored`,
120
+ );
121
+ continue;
122
+ }
123
+ const clamped = clampChars(feedback, round.maxInjectChars);
124
+ if (result.volatile) {
125
+ yield await emitCheckpointEvent(ctx, 'checkpoint_injected', {
126
+ name: cp.name,
127
+ volatile: true,
128
+ });
129
+ return { kind: 'loop', volatileText: clamped };
130
+ }
131
+ // Persistent: a checkpoint-origin user prompt. Projected as a
132
+ // user-role message by the existing projection case, marked via the
133
+ // same `origin` machinery trigger prompts use, cache-safe because it
134
+ // is an ordinary append at the log tail.
135
+ yield await ctx.emit({
136
+ type: 'user_prompt',
137
+ sessionId: ctx.sessionId,
138
+ turnId: ctx.turnId,
139
+ source: 'system',
140
+ text: clamped,
141
+ origin: { kind: 'checkpoint', name: cp.name },
142
+ });
143
+ yield await emitCheckpointEvent(ctx, 'checkpoint_injected', {
144
+ name: cp.name,
145
+ volatile: false,
146
+ });
147
+ return { kind: 'loop' };
148
+ }
149
+ }
150
+ }
151
+ return { kind: 'end' };
152
+ }
153
+
154
+ function clampChars(text: string, max: number): string {
155
+ if (text.length <= max) return text;
156
+ return `${text.slice(0, max)}\n…[checkpoint feedback truncated: ${text.length - max} chars dropped]`;
157
+ }
158
+
159
+ export async function emitAbort(ctx: ModeContext, reason: string): Promise<MoxxyEvent> {
160
+ return ctx.emit({
161
+ type: 'abort',
162
+ sessionId: ctx.sessionId,
163
+ turnId: ctx.turnId,
164
+ source: 'system',
165
+ reason,
166
+ });
167
+ }
168
+
169
+ export async function emitError(
170
+ ctx: ModeContext,
171
+ kind: 'retryable' | 'fatal',
172
+ message: string,
173
+ ): Promise<MoxxyEvent> {
174
+ return ctx.emit({
175
+ type: 'error',
176
+ sessionId: ctx.sessionId,
177
+ turnId: ctx.turnId,
178
+ source: 'system',
179
+ kind,
180
+ message,
181
+ });
182
+ }
183
+
184
+ export async function emitCheckpointEvent(
185
+ ctx: ModeContext,
186
+ subtype: string,
187
+ payload: unknown,
188
+ ): Promise<MoxxyEvent> {
189
+ return ctx.emit({
190
+ type: 'plugin_event',
191
+ sessionId: ctx.sessionId,
192
+ turnId: ctx.turnId,
193
+ source: 'system',
194
+ pluginId: CHECKPOINT_EVENT_PLUGIN_ID,
195
+ subtype,
196
+ payload,
197
+ });
198
+ }
@@ -101,6 +101,12 @@ export interface TurnCheckpoint {
101
101
  * (goal/collab-style stall handling wants these too).
102
102
  */
103
103
  readonly gateOn?: 'end_turn' | 'idle';
104
+ /**
105
+ * Also gate turns done by a vendor's agent (an agent connection). Off by
106
+ * default: the agent decides when its turn is done, as in its own tool, so
107
+ * Moxxy's end-of-turn reminders stay out of it.
108
+ */
109
+ readonly agentConnections?: boolean;
104
110
  /**
105
111
  * A cheap test of whether this turn needs the checkpoint at all. When it
106
112
  * says no, the checkpoint is skipped without a trace — no lifecycle events —
@@ -22,7 +22,7 @@ export interface CollectedToolUse {
22
22
  }
23
23
 
24
24
  /** Sum two usage frames so multi-`message_end` responses don't undercount. */
25
- function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
25
+ export function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
26
26
  const cacheRead =
27
27
  a.cacheReadTokens !== undefined || b.cacheReadTokens !== undefined
28
28
  ? (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0)
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { projectMessagesFromLog, projectUserPrompt, resolvedCallIdSet, skillsWithinReach } from './project-messages.js';
2
+ import { MODEL_ATTACHMENT_BUDGET_CHARS } from '../attachment-payload.js';
3
+ import {
4
+ projectMessagesFromLog,
5
+ projectUserPrompt,
6
+ resolvedCallIdSet,
7
+ skillsWithinReach,
8
+ } from './project-messages.js';
3
9
  import type { Skill } from '../skill.js';
4
10
  import { computeElisionState } from '../elision-state.js';
5
11
  import { asEventId, asSessionId, asTurnId } from '../ids.js';
@@ -181,6 +187,17 @@ describe('projectMessagesFromLog tool_result stringify hardening', () => {
181
187
  it('passes a string output through verbatim', () => {
182
188
  expect(toolResultText(logWith('plain text'))).toBe('plain text');
183
189
  });
190
+
191
+ it('shows the model every image a result carries, after its text', () => {
192
+ const output = { forModel: 'Two windows', mediaType: 'image/png', base64: 'AAA', moreImages: [{ mediaType: 'image/jpeg', base64: 'BBB' }] };
193
+ const msgs = projectMessagesFromLog({ log: reader(logWith(output)) });
194
+
195
+ expect(msgs.find((m) => m.role === 'tool_result')?.content).toEqual([
196
+ { type: 'tool_result', toolUseId: 'c1', content: 'Two windows', isError: false },
197
+ { type: 'image', mediaType: 'image/png', data: 'AAA' },
198
+ { type: 'image', mediaType: 'image/jpeg', data: 'BBB' },
199
+ ]);
200
+ });
184
201
  });
185
202
 
186
203
  describe('skillsWithinReach', () => {
@@ -252,3 +269,103 @@ describe('projectMessagesFromLog tool pairing across a compaction boundary', ()
252
269
  });
253
270
  });
254
271
  });
272
+
273
+ describe('projectMessagesFromLog attachment budget', () => {
274
+ const pdf = (name: string, chars: number) => ({
275
+ kind: 'document' as const,
276
+ content: 'A'.repeat(chars),
277
+ mediaType: 'application/pdf',
278
+ name,
279
+ });
280
+ const blocksOf = (events: ReadonlyArray<MoxxyEvent>) =>
281
+ projectMessagesFromLog({ log: reader(events) }).flatMap((m) => m.content);
282
+
283
+ it('sends the newest attachments in full and names the older ones that no longer fit', () => {
284
+ const half = Math.floor(MODEL_ATTACHMENT_BUDGET_CHARS * 0.6);
285
+ const blocks = blocksOf([
286
+ userPrompt(0, { text: 'first scan', attachments: [pdf('old-scan.pdf', half)] }),
287
+ userPrompt(1, { text: 'second scan', attachments: [pdf('new-scan.pdf', half)] }),
288
+ ]);
289
+ const documents = blocks.filter((b) => b.type === 'document');
290
+ expect(documents).toHaveLength(1);
291
+ expect(documents[0]).toMatchObject({ name: 'new-scan.pdf' });
292
+ const note = blocks.find((b) => b.type === 'text' && b.text.includes('old-scan.pdf'));
293
+ expect(note).toBeDefined();
294
+ const sent = blocks.reduce((n, b) => n + (b.type === 'document' ? b.data.length : 0), 0);
295
+ expect(sent).toBeLessThanOrEqual(MODEL_ATTACHMENT_BUDGET_CHARS);
296
+ });
297
+
298
+ it('tells the model how to bring a left-out attachment back', () => {
299
+ const half = Math.floor(MODEL_ATTACHMENT_BUDGET_CHARS * 0.6);
300
+ const blocks = blocksOf([
301
+ userPrompt(0, { text: 'first scan', attachments: [pdf('old-scan.pdf', half)] }),
302
+ userPrompt(1, { text: 'second scan', attachments: [pdf('new-scan.pdf', half)] }),
303
+ ]);
304
+ const note = blocks.find((b) => b.type === 'text' && b.text.includes('old-scan.pdf'));
305
+ expect(note).toMatchObject({ text: expect.stringContaining('recall({ attachment: "0:0" })') });
306
+ });
307
+
308
+ const restore = (seq: number, callId: string, ref: { seq: number; index: number }): MoxxyEvent[] => [
309
+ event(seq, { type: 'tool_call_requested', turnId: t1, source: 'model', callId, name: 'recall', input: { attachment: `${ref.seq}:${ref.index}` } }),
310
+ event(seq + 1, {
311
+ type: 'tool_result',
312
+ turnId: t1,
313
+ source: 'tool',
314
+ callId,
315
+ ok: true,
316
+ output: { forModel: 'restored old-scan.pdf', restoredAttachment: ref },
317
+ }),
318
+ ];
319
+
320
+ it('shows a restored attachment again beside the recall result, within the same budget', () => {
321
+ const half = Math.floor(MODEL_ATTACHMENT_BUDGET_CHARS * 0.6);
322
+ const messages = projectMessagesFromLog({
323
+ log: reader([
324
+ userPrompt(0, { text: 'first scan', attachments: [pdf('old-scan.pdf', half)] }),
325
+ userPrompt(1, { text: 'second scan', attachments: [pdf('new-scan.pdf', half)] }),
326
+ userPrompt(2, { text: 'back to the first one' }),
327
+ ...restore(3, 'r1', { seq: 0, index: 0 }),
328
+ ]),
329
+ });
330
+ const result = messages.find((m) => m.role === 'tool_result');
331
+ expect(result?.content).toEqual([
332
+ { type: 'tool_result', toolUseId: 'r1', content: 'restored old-scan.pdf', isError: false },
333
+ { type: 'document', mediaType: 'application/pdf', data: 'A'.repeat(half), name: 'old-scan.pdf' },
334
+ ]);
335
+ const blocks = messages.flatMap((m) => m.content);
336
+ expect(blocks.filter((b) => b.type === 'document')).toHaveLength(1);
337
+ expect(blocks.some((b) => b.type === 'text' && b.text.includes('new-scan.pdf'))).toBe(true);
338
+ });
339
+
340
+ it('restores an attachment whose turn a compaction summarized away', () => {
341
+ const messages = projectMessagesFromLog({
342
+ log: reader([
343
+ userPrompt(0, { text: 'the contract', attachments: [pdf('umowa.pdf', 100)] }),
344
+ event(1, {
345
+ type: 'compaction',
346
+ turnId: t1,
347
+ source: 'compactor',
348
+ compactor: 'segments',
349
+ replacedRange: [0, 0],
350
+ summary: 'the person shared umowa.pdf',
351
+ tokensSaved: 10,
352
+ }),
353
+ userPrompt(2, { text: 'what was in point 7?' }),
354
+ ...restore(3, 'r1', { seq: 0, index: 0 }),
355
+ ]),
356
+ });
357
+ const documents = messages.flatMap((m) => m.content).filter((b) => b.type === 'document');
358
+ expect(documents).toEqual([{ type: 'document', mediaType: 'application/pdf', data: 'A'.repeat(100), name: 'umowa.pdf' }]);
359
+ });
360
+
361
+ it('never sends an empty payload for an attachment whose bytes were left out', () => {
362
+ const blocks = projectUserPrompt(
363
+ userPrompt(0, {
364
+ attachments: [{ kind: 'image', content: '', mediaType: 'image/png', name: 'shot.png', omittedBytes: 2048 }],
365
+ }),
366
+ noElision,
367
+ );
368
+ expect(blocks.some((b) => b.type === 'image')).toBe(false);
369
+ expect(blocks.some((b) => b.type === 'text' && b.text.includes('shot.png'))).toBe(true);
370
+ });
371
+ });
@@ -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,
@@ -16,51 +22,7 @@ import {
16
22
  toolResultStubbed,
17
23
  type ElisionState,
18
24
  } from '../elision-state.js';
19
- import { isToolDisplayResult } from '../tool-display.js';
20
-
21
- /**
22
- * Stringify an arbitrary tool output for the model-facing tool_result text.
23
- * `tool_result.output` is `unknown` and comes straight from whatever a tool
24
- * returned, so it can be circular or contain a BigInt — either of which makes
25
- * `JSON.stringify` THROW. Because projection runs on every request over the
26
- * append-only log, an unguarded throw here permanently wedges the session
27
- * (every subsequent re-projection re-throws). Mirrors the try/catch guard the
28
- * sibling sizing paths (`safeJsonLen`, `toolResultBytes`) already use.
29
- */
30
- function safeStringifyOutput(output: unknown): string {
31
- if (typeof output === 'string') return output;
32
- try {
33
- return JSON.stringify(output ?? '');
34
- } catch {
35
- try {
36
- return String(output ?? '');
37
- } catch {
38
- return '[unserializable tool output]';
39
- }
40
- }
41
- }
42
-
43
- /**
44
- * An image ContentBlock extracted from an image-shaped tool output. A tool can
45
- * return either `{ mediaType, base64 }` (the desktop screenshot/clipboard shape)
46
- * or the provider-native `{ type:'image', mediaType, data }` — both normalize to
47
- * the same provider `image` block so the model SEES the pixels instead of a
48
- * stringified blob of base64.
49
- */
50
- function imageBlockFromOutput(output: unknown): Extract<ContentBlock, { type: 'image' }> | null {
51
- if (typeof output !== 'object' || output === null) return null;
52
- const o = output as { type?: unknown; mediaType?: unknown; base64?: unknown; data?: unknown };
53
- if (typeof o.mediaType !== 'string') return null;
54
- // `{ mediaType, base64 }` (raw shape) or `{ type:'image', mediaType, data }`.
55
- const data =
56
- typeof o.base64 === 'string'
57
- ? o.base64
58
- : o.type === 'image' && typeof o.data === 'string'
59
- ? o.data
60
- : null;
61
- if (data === null) return null;
62
- return { type: 'image', mediaType: o.mediaType, data };
63
- }
25
+ import { toolOutputForModel, type ToolImageBlock } from '../tool-output.js';
64
26
 
65
27
  /** Appended to the system prompt while elision is active (see projection). */
66
28
  export const ELISION_SYSTEM_NOTE =
@@ -135,6 +97,96 @@ export interface ProjectMessagesOptions {
135
97
  readonly precomputedElisionState?: ElisionState;
136
98
  }
137
99
 
100
+ function hasModelPayload(att: UserPromptAttachment): boolean {
101
+ return att.kind === 'image' || att.kind === 'document';
102
+ }
103
+
104
+ function attachmentLeftOutNote(att: UserPromptAttachment, seq: number, index: number): string {
105
+ const label = `${att.kind}${att.name ? ` "${att.name}"` : ''}`;
106
+ const mb = (attachmentBytes(att) / (1024 * 1024)).toFixed(1);
107
+ return (
108
+ `[${label} (${mb} MB) was attached earlier in this conversation and is no longer sent ` +
109
+ `with each request, to keep the request within the model's size limit. ` +
110
+ `To see it again, call recall({ attachment: "${attachmentKey(seq, index)}" }).]`
111
+ );
112
+ }
113
+
114
+ /** The image or document block an attachment the model reads as bytes becomes. */
115
+ function attachmentBlock(att: UserPromptAttachment): ContentBlock {
116
+ if (att.kind === 'image') {
117
+ return { type: 'image', mediaType: att.mediaType ?? 'image/png', data: att.content };
118
+ }
119
+ return {
120
+ type: 'document',
121
+ mediaType: att.mediaType ?? 'application/pdf',
122
+ data: att.content,
123
+ ...(att.name ? { name: att.name } : {}),
124
+ };
125
+ }
126
+
127
+ /** The prompts that carry attachments, by seq, so a restoring recall finds its original. */
128
+ function attachmentPromptsBySeq(events: ReadonlyArray<MoxxyEvent>): ReadonlyMap<number, UserPromptEvent> {
129
+ const prompts = new Map<number, UserPromptEvent>();
130
+ for (const e of events) {
131
+ if (e.type === 'user_prompt' && e.attachments && e.attachments.length > 0) prompts.set(e.seq, e);
132
+ }
133
+ return prompts;
134
+ }
135
+
136
+ /** The image/document a `recall({ attachment })` result brings back, or null. */
137
+ function restoredAttachment(
138
+ e: Extract<MoxxyEvent, { type: 'tool_result' }>,
139
+ prompts: ReadonlyMap<number, UserPromptEvent>,
140
+ ): UserPromptAttachment | null {
141
+ if (!e.ok || e.error) return null;
142
+ const ref = restoredAttachmentRef(e.output);
143
+ if (!ref) return null;
144
+ const prompt = prompts.get(ref.seq);
145
+ const att = prompt ? prompt.attachments?.[ref.index] : undefined;
146
+ return att && hasModelPayload(att) && att.omittedBytes === undefined ? att : null;
147
+ }
148
+
149
+ /** The budget key of what a restoring recall result shows. */
150
+ const restoreKey = (seq: number): string => `r:${seq}`;
151
+
152
+ /**
153
+ * Keys of the image/document payloads that go to the model in full: an
154
+ * attachment's `seq:index`, or `r:seq` for one a recall result restores.
155
+ * Newest first, while they fit {@link MODEL_ATTACHMENT_BUDGET_CHARS}, so a
156
+ * restored attachment takes its place among the newest. Deterministic for a
157
+ * given log, so the prompt cache sees a stable prefix.
158
+ */
159
+ function attachmentsSentInFull(
160
+ events: ReadonlyArray<MoxxyEvent>,
161
+ el: ElisionState,
162
+ isCompacted: (seq: number) => boolean,
163
+ prompts: ReadonlyMap<number, UserPromptEvent>,
164
+ ): ReadonlySet<string> {
165
+ const keys = new Set<string>();
166
+ let room = MODEL_ATTACHMENT_BUDGET_CHARS;
167
+ const take = (key: string, att: UserPromptAttachment): void => {
168
+ if (att.content.length > room) return;
169
+ room -= att.content.length;
170
+ keys.add(key);
171
+ };
172
+ for (let i = events.length - 1; i >= 0; i -= 1) {
173
+ const e = events[i];
174
+ if (!e || isCompacted(e.seq)) continue;
175
+ if (e.type === 'tool_result') {
176
+ if (toolResultStubbed(e, el)) continue;
177
+ const att = restoredAttachment(e, prompts);
178
+ if (att) take(restoreKey(e.seq), att);
179
+ continue;
180
+ }
181
+ if (e.type !== 'user_prompt' || !e.attachments || conversationalStubbed(e, el)) continue;
182
+ for (const [index, att] of e.attachments.entries()) {
183
+ if (!hasModelPayload(att) || att.omittedBytes !== undefined) continue;
184
+ take(attachmentKey(e.seq, index), att);
185
+ }
186
+ }
187
+ return keys;
188
+ }
189
+
138
190
  /**
139
191
  * Pure projection of a single `user_prompt` event to its content blocks.
140
192
  *
@@ -144,27 +196,23 @@ export interface ProjectMessagesOptions {
144
196
  * what the inline switch arm produced. Caller decides how to wrap it as a
145
197
  * message / record the stable prefix.
146
198
  */
147
- export function projectUserPrompt(event: UserPromptEvent, el: ElisionState): ContentBlock[] {
199
+ export function projectUserPrompt(
200
+ event: UserPromptEvent,
201
+ el: ElisionState,
202
+ /** Which attachments go to the model in full; the rest become a short note. Default: all. */
203
+ sendsInFull: (index: number) => boolean = () => true,
204
+ ): ContentBlock[] {
148
205
  // Elided + conversational: collapse to a stub (anchor/tiny kept full).
149
206
  if (conversationalStubbed(event, el)) {
150
207
  return [{ type: 'text', text: conversationalStub('user', event.seq) }];
151
208
  }
152
209
  const blocks: ContentBlock[] = [{ type: 'text', text: event.text }];
153
210
  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
- });
211
+ for (const [index, att] of event.attachments.entries()) {
212
+ if (att.omittedBytes !== undefined || (hasModelPayload(att) && !sendsInFull(index))) {
213
+ blocks.push({ type: 'text', text: attachmentLeftOutNote(att, event.seq, index) });
214
+ } else if (hasModelPayload(att)) {
215
+ blocks.push(attachmentBlock(att));
168
216
  } else {
169
217
  blocks.push({
170
218
  type: 'text',
@@ -277,6 +325,8 @@ export function projectMessages(
277
325
  // Only VISIBLE results count: one swallowed by a compaction is never sent, so
278
326
  // its still-visible tool_use needs the fallback just like a missing one.
279
327
  const resolvedCallIds = resolvedCallIdSet(allEvents.filter((e) => !compactionFor(e.seq)));
328
+ const attachmentPrompts = attachmentPromptsBySeq(allEvents);
329
+ const sentInFull = attachmentsSentInFull(allEvents, el, (seq) => compactionFor(seq) !== null, attachmentPrompts);
280
330
  // callIds whose tool_use block is projected. A tool_result outside this set
281
331
  // answers a call a compaction summarized away (the result landed after later
282
332
  // turns began); providers reject a result with no matching call.
@@ -347,7 +397,10 @@ export function projectMessages(
347
397
  case 'user_prompt': {
348
398
  flush();
349
399
  pendingReasoning = null;
350
- messages.push({ role: 'user', content: projectUserPrompt(e, el) });
400
+ messages.push({
401
+ role: 'user',
402
+ content: projectUserPrompt(e, el, (index) => sentInFull.has(`${e.seq}:${index}`)),
403
+ });
351
404
  recordStable(e.seq);
352
405
  break;
353
406
  }
@@ -427,32 +480,38 @@ export function projectMessages(
427
480
  // Stub bulky old tool output to a recall-able marker (decision shared
428
481
  // with estimateContextTokens via toolResultStubbed).
429
482
  let text: string;
430
- // An image-shaped output (a screenshot/clipboard grab) is carried as a
431
- // provider `image` block so the model SEES the pixels — only when the
483
+ // An image-shaped output (a screenshot/clipboard grab) is carried as
484
+ // provider `image` blocks so the model SEES the pixels — only when the
432
485
  // result isn't stubbed (elided) or an error and the call succeeded.
433
- let image: Extract<ContentBlock, { type: 'image' }> | null = null;
486
+ let images: ReadonlyArray<ToolImageBlock> = [];
487
+ // A `recall({ attachment })` result: the original image/document goes
488
+ // beside it, from the prompt that carried it, while the budget allows.
489
+ let restored: ContentBlock | null = null;
490
+ const restoredAtt = toolResultStubbed(e, el) ? null : restoredAttachment(e, attachmentPrompts);
434
491
  if (toolResultStubbed(e, el)) {
435
492
  text = toolResultStubText(e, el);
436
493
  } else if (e.error) {
437
494
  text = `[error:${e.error.kind}] ${e.error.message}`;
438
- } else if (isToolDisplayResult(e.output)) {
439
- // Rich result (e.g. a file diff): the model only needs the short
440
- // `forModel` summary — the structured `display` is for channels.
441
- text = e.output.forModel;
442
- } else if (e.ok && (image = imageBlockFromOutput(e.output))) {
443
- // Short marker satisfies the tool_use→tool_result pairing; the image
444
- // block (appended below) carries the actual pixels.
495
+ } else if (restoredAtt) {
445
496
  const description = (e.output as { forModel?: unknown }).forModel;
446
- text = typeof description === 'string'
447
- ? description
448
- : '[image returned by tool — see attached image]';
497
+ text = typeof description === 'string' ? description : '[attachment restored — see below]';
498
+ if (sentInFull.has(restoreKey(e.seq))) {
499
+ restored = attachmentBlock(restoredAtt);
500
+ } else {
501
+ text +=
502
+ ' [It could not be shown again: newer attachments already fill what one request may carry.' +
503
+ ' Ask the person to attach it again if you need it now.]';
504
+ }
449
505
  } else {
450
- text = safeStringifyOutput(e.output);
506
+ const forModel = toolOutputForModel(e.output);
507
+ text = forModel.text;
508
+ if (e.ok) images = forModel.images;
451
509
  }
452
510
  const content: ContentBlock[] = [
453
511
  { type: 'tool_result', toolUseId: e.callId, content: text, isError: !e.ok },
512
+ ...images,
454
513
  ];
455
- if (image) content.push(image);
514
+ if (restored) content.push(restored);
456
515
  messages.push({ role: 'tool_result', content });
457
516
  recordStable(e.seq);
458
517
  break;