@mindstudio-ai/remy 0.1.260 → 0.1.261
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/README.md +3 -1
- package/dist/headless.d.ts +70 -9
- package/dist/headless.js +277 -112
- package/dist/index.js +278 -113
- package/dist/prompt/static/instructions.md +1 -1
- package/dist/prompt/static/team.md +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -264,6 +264,7 @@ The headless IPC protocol uses request correlation and a unified response patter
|
|
|
264
264
|
- Every stdout response to a command includes the same `requestId`
|
|
265
265
|
- System events (lifecycle, shutdown) never have a `requestId`
|
|
266
266
|
- Every command ends with exactly one `completed` event: `{event:"completed", requestId, success, error?}`
|
|
267
|
+
- Messages sent while a turn is running are queued. When the turn ends, all contiguous queued user messages and background results are delivered together as **one merged turn**: the first queued message's `requestId` becomes the turn's primary id (stamped on `turn_started` and all streaming events), each absorbed message echoes its own `user_message` with its original `requestId` and `queued: true`, and at turn end the primary `completed` is emitted first, followed immediately by one `completed {…same outcome, absorbed: true}` per other absorbed `requestId`. Automated-action (`@@automated::…@@`) messages and chain steps never merge — they always run one turn each.
|
|
267
268
|
- The caller distinguishes command responses from system events with a single check: `if (msg.requestId)`
|
|
268
269
|
|
|
269
270
|
This enables a simple promise-based RPC layer: send a command with a unique ID, store a pending promise keyed by that ID, resolve it when you see `completed` with the matching ID.
|
|
@@ -364,6 +365,7 @@ All command responses include the `requestId` from the originating command.
|
|
|
364
365
|
|-------|--------|-------------|
|
|
365
366
|
| `text` | `text`, `parentToolId?` | Streaming text chunk |
|
|
366
367
|
| `thinking` | `text`, `parentToolId?` | Agent's internal reasoning |
|
|
368
|
+
| `user_message` | `text`, `attachments?`, `queued?` | Echo of a user message entering the turn. Queue-delivered messages carry `queued: true` and their own original `requestId` (a merged turn emits one per absorbed message); idle sends echo with the turn's requestId and no `queued` flag. |
|
|
367
369
|
| `tool_start` | `id`, `name`, `input`, `partial?`, `parentToolId?` | Tool execution started. `partial: true` means more `tool_start` events will follow for this id (progressive input streaming). |
|
|
368
370
|
| `tool_input_delta` | `id`, `name`, `result`, `parentToolId?` | Progressive tool content (streaming tools only) |
|
|
369
371
|
| `tool_done` | `id`, `name`, `result`, `isError`, `parentToolId?` | Tool execution completed |
|
|
@@ -372,7 +374,7 @@ All command responses include the `requestId` from the originating command.
|
|
|
372
374
|
| `history` | `messages` | Response to `get_history` |
|
|
373
375
|
| `session_cleared` | | Response to `clear` |
|
|
374
376
|
| `models_changed` | `models?`, `modelSurfaces`, `allowedModelsByType` | Response to `changeModels` |
|
|
375
|
-
| `completed` | `success`, `error?` | Terminal event — exactly one per command |
|
|
377
|
+
| `completed` | `success`, `error?`, `absorbed?` | Terminal event — exactly one per command. For a merged turn, the primary requestId's `completed` comes first, then one `{absorbed: true}` completed per other absorbed requestId with the same outcome — consumers should resolve their pending request but skip turn-lifecycle handling for absorbed terminals. |
|
|
376
378
|
|
|
377
379
|
#### Example Session
|
|
378
380
|
|
package/dist/headless.d.ts
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* - System events (ready, session_restored, stopping, stopped) never have a requestId.
|
|
12
12
|
* - Every command ends with exactly one `completed` event:
|
|
13
13
|
* {event:"completed", requestId, success:true|false, error?:string}
|
|
14
|
+
* - When contiguous queued user/background messages merge into one turn, the
|
|
15
|
+
* turn's primary requestId gets the real `completed` first, then each other
|
|
16
|
+
* absorbed requestId gets one `{...same outcome, absorbed:true}` completed
|
|
17
|
+
* immediately after — one terminal per command either way.
|
|
14
18
|
* - `tool_result` is fire-and-forget (resolves an in-flight promise, no completed event).
|
|
15
19
|
*
|
|
16
20
|
* `get_history` is paginated. Request: {action:"get_history", before?:number,
|
|
@@ -46,8 +50,12 @@ declare class HeadlessSession {
|
|
|
46
50
|
private currentAbort;
|
|
47
51
|
/** RequestId of the in-flight message command — injected into streamed events. */
|
|
48
52
|
private currentRequestId;
|
|
49
|
-
/** Guard: track whether terminal `completed` was already sent so we emit
|
|
53
|
+
/** Guard: track whether terminal `completed` was already sent so we emit
|
|
54
|
+
* exactly one per requestId. */
|
|
50
55
|
private completedEmitted;
|
|
56
|
+
/** Outcome of the current turn's primary `completed` — read after the turn
|
|
57
|
+
* to stamp the same success/error onto absorbed requestIds' terminals. */
|
|
58
|
+
private lastCompleted;
|
|
51
59
|
private turnStart;
|
|
52
60
|
/**
|
|
53
61
|
* Onboarding state of the currently-running turn. Captured at runSingleTurn
|
|
@@ -74,6 +82,9 @@ declare class HeadlessSession {
|
|
|
74
82
|
/** Emit a `completed` event and mark completedEmitted. Queue state is
|
|
75
83
|
* surfaced separately via the `queue_changed` event, not on `completed`. */
|
|
76
84
|
private emitCompleted;
|
|
85
|
+
/** Outcome of the turn's primary `completed`, for stamping onto absorbed
|
|
86
|
+
* requestIds' terminals. Falls back to failure if none was emitted. */
|
|
87
|
+
private primaryOutcome;
|
|
77
88
|
/** Dispatch a simple (non-streaming) command: call handler, emit response + completed. */
|
|
78
89
|
private dispatchSimple;
|
|
79
90
|
/** Persist sessionStats + queue snapshot to .remy-stats.json. */
|
|
@@ -98,20 +109,65 @@ declare class HeadlessSession {
|
|
|
98
109
|
private resolveExternalTool;
|
|
99
110
|
private onEvent;
|
|
100
111
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
112
|
+
* Persist one entry's non-voice uploads to disk and build its header. The
|
|
113
|
+
* header tells the LLM where to read each file; it's kept separate so it
|
|
114
|
+
* gets injected at API-send time and never persisted into the user's chat
|
|
115
|
+
* content (which would leak into history restore on the frontend).
|
|
116
|
+
*
|
|
117
|
+
* Must be awaited sequentially across entries — persistAttachments'
|
|
118
|
+
* filename de-dup set is per-call, so parallel calls race on names.
|
|
119
|
+
*/
|
|
120
|
+
private persistEntryAttachments;
|
|
121
|
+
/**
|
|
122
|
+
* Run one turn for a single command (without acquiring the `running` lock).
|
|
123
|
+
* Owns the per-command machinery: @@automated:: action resolution, plan-file
|
|
124
|
+
* and buildModel side effects, and chain expansion — which is why
|
|
125
|
+
* sentinel-bearing commands always come through here, one turn each, never
|
|
126
|
+
* merged. The turn itself runs in executeTurn.
|
|
105
127
|
*/
|
|
106
128
|
private runSingleTurn;
|
|
129
|
+
/**
|
|
130
|
+
* Run a mailbox batch — contiguous queued user + background items — as one
|
|
131
|
+
* merged turn. Every item becomes its own history entry and user_message
|
|
132
|
+
* event (own requestId, attachments, hidden flag); adjacent background
|
|
133
|
+
* items fold into a single background_results entry so the LLM sees one
|
|
134
|
+
* combined block. No action-sentinel machinery here: batch construction
|
|
135
|
+
* guarantees none (sentinel-bearing user items are drain barriers that run
|
|
136
|
+
* alone via runSingleTurn, and background_results is a NON_ACTION sentinel
|
|
137
|
+
* with no side effects).
|
|
138
|
+
*/
|
|
139
|
+
private runMergedTurn;
|
|
140
|
+
/**
|
|
141
|
+
* Run one agent turn over the given entries (without acquiring the
|
|
142
|
+
* `running` lock). Owns the turn-generic lifecycle: request bookkeeping,
|
|
143
|
+
* the forced-compaction gate, runTurn error handling, and terminal
|
|
144
|
+
* `completed` events — the primary requestId's completed first, then one
|
|
145
|
+
* `{absorbed: true}` completed per other absorbed requestId with the same
|
|
146
|
+
* outcome, on every exit path (done, cancel, error, unexpected), so every
|
|
147
|
+
* queued message's caller resolves.
|
|
148
|
+
*/
|
|
149
|
+
private executeTurn;
|
|
107
150
|
private handleMessage;
|
|
151
|
+
/**
|
|
152
|
+
* True for queued user items whose text is an @@automated:: action message.
|
|
153
|
+
* These key per-item raw-text side effects in runSingleTurn (resolveAction,
|
|
154
|
+
* plan file, buildModel, chain expansion) — they always run alone, one turn
|
|
155
|
+
* each. Background items are sentinel-formatted too but background_results
|
|
156
|
+
* is NON_ACTION and side-effect-free, so they merge freely.
|
|
157
|
+
*/
|
|
158
|
+
private isDrainBarrier;
|
|
108
159
|
/**
|
|
109
160
|
* Drain the queue in strict FIFO order. Caller must hold `running = true`.
|
|
110
161
|
* User messages arriving during the drain will be enqueued behind current items.
|
|
111
162
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
163
|
+
* The queue serves two purposes with opposite delivery semantics:
|
|
164
|
+
* - Sequencer: chain steps and sentinel-bearing user items are pipeline
|
|
165
|
+
* stages — one item, one turn, nothing merged in.
|
|
166
|
+
* - Mailbox: plain user messages and background results are accumulated
|
|
167
|
+
* context and intent — everything contiguous flushes together into ONE
|
|
168
|
+
* merged turn, so the model reconciles all of it at once instead of
|
|
169
|
+
* burning a full turn per item (and possibly executing instructions a
|
|
170
|
+
* later queued message already amended).
|
|
115
171
|
*/
|
|
116
172
|
private drainQueueLoop;
|
|
117
173
|
/**
|
|
@@ -136,9 +192,14 @@ declare class HeadlessSession {
|
|
|
136
192
|
* Cancel the running turn and flush the follow-ups that belonged to it
|
|
137
193
|
* (`chain`/`background`), while preserving `source: 'user'` items — those are
|
|
138
194
|
* independent user intent, not tied to the aborted run. The preserved user
|
|
139
|
-
* messages run next: `
|
|
195
|
+
* messages run next: `executeTurn` swallows the abort, so `handleMessage`
|
|
140
196
|
* falls through to `drainQueueLoop` with `running` still held. Returns the
|
|
141
197
|
* flushed items (for the cancel command's resume/discard UX).
|
|
198
|
+
*
|
|
199
|
+
* Messages already absorbed into the in-flight merged turn are NOT
|
|
200
|
+
* preserved — they were delivered into the turn that's being cancelled and
|
|
201
|
+
* each gets a `{cancelled, absorbed:true}` terminal. Only items still
|
|
202
|
+
* sitting in the queue survive.
|
|
142
203
|
*/
|
|
143
204
|
private handleCancel;
|
|
144
205
|
/**
|
package/dist/headless.js
CHANGED
|
@@ -7614,9 +7614,7 @@ function createAgentState() {
|
|
|
7614
7614
|
async function runTurn(params) {
|
|
7615
7615
|
const {
|
|
7616
7616
|
state,
|
|
7617
|
-
|
|
7618
|
-
attachments,
|
|
7619
|
-
attachmentHeader,
|
|
7617
|
+
entries,
|
|
7620
7618
|
apiConfig,
|
|
7621
7619
|
system,
|
|
7622
7620
|
model,
|
|
@@ -7625,7 +7623,6 @@ async function runTurn(params) {
|
|
|
7625
7623
|
signal,
|
|
7626
7624
|
onEvent,
|
|
7627
7625
|
resolveExternalTool,
|
|
7628
|
-
hidden,
|
|
7629
7626
|
requestId,
|
|
7630
7627
|
toolRegistry,
|
|
7631
7628
|
onBackgroundComplete
|
|
@@ -7635,45 +7632,54 @@ async function runTurn(params) {
|
|
|
7635
7632
|
const baseline = resolveModel("parent", state.models, model);
|
|
7636
7633
|
const parentModel = buildModelOverride ?? baseline;
|
|
7637
7634
|
const modelOverride = buildModelOverride && buildModelOverride !== baseline ? { from: baseline } : void 0;
|
|
7635
|
+
const totalAttachments = entries.reduce(
|
|
7636
|
+
(n, e) => n + (e.attachments?.length ?? 0),
|
|
7637
|
+
0
|
|
7638
|
+
);
|
|
7638
7639
|
log14.info("Turn started", {
|
|
7639
7640
|
requestId,
|
|
7640
7641
|
model,
|
|
7641
7642
|
buildModel: buildModelOverride,
|
|
7642
7643
|
toolCount: tools2.length,
|
|
7643
|
-
...
|
|
7644
|
-
|
|
7645
|
-
}
|
|
7644
|
+
...entries.length > 1 && { entryCount: entries.length },
|
|
7645
|
+
...totalAttachments > 0 && { attachmentCount: totalAttachments }
|
|
7646
7646
|
});
|
|
7647
7647
|
onEvent({
|
|
7648
7648
|
type: "turn_started",
|
|
7649
7649
|
model: parentModel,
|
|
7650
7650
|
...modelOverride && { modelOverride }
|
|
7651
7651
|
});
|
|
7652
|
-
const
|
|
7653
|
-
|
|
7654
|
-
|
|
7652
|
+
const keptEntries = entries.filter(
|
|
7653
|
+
(e) => e.text.trim().length > 0 || (e.attachments?.length ?? 0) > 0
|
|
7654
|
+
);
|
|
7655
|
+
if (keptEntries.length === 0) {
|
|
7655
7656
|
onEvent({ type: "error", error: "Empty message" });
|
|
7656
7657
|
return;
|
|
7657
7658
|
}
|
|
7658
|
-
const
|
|
7659
|
-
|
|
7660
|
-
userMsg
|
|
7661
|
-
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7659
|
+
for (const entry of keptEntries) {
|
|
7660
|
+
const hasAttachments = (entry.attachments?.length ?? 0) > 0;
|
|
7661
|
+
const userMsg = { role: "user", content: entry.text };
|
|
7662
|
+
if (entry.hidden) {
|
|
7663
|
+
userMsg.hidden = true;
|
|
7664
|
+
}
|
|
7665
|
+
if (hasAttachments) {
|
|
7666
|
+
userMsg.attachments = entry.attachments;
|
|
7667
|
+
}
|
|
7668
|
+
if (entry.attachmentHeader) {
|
|
7669
|
+
userMsg.attachmentHeader = entry.attachmentHeader;
|
|
7670
|
+
}
|
|
7671
|
+
state.messages.push(userMsg);
|
|
7672
|
+
onEvent({
|
|
7673
|
+
type: "user_message",
|
|
7674
|
+
text: entry.text,
|
|
7675
|
+
hidden: entry.hidden || void 0,
|
|
7676
|
+
// Include attachments so the live event can render a queued voice/image/file
|
|
7677
|
+
// bubble; a voice message has empty text and the transcript lives here.
|
|
7678
|
+
...hasAttachments && { attachments: entry.attachments },
|
|
7679
|
+
...entry.requestId && { requestId: entry.requestId },
|
|
7680
|
+
...entry.queued && { queued: true }
|
|
7681
|
+
});
|
|
7667
7682
|
}
|
|
7668
|
-
state.messages.push(userMsg);
|
|
7669
|
-
onEvent({
|
|
7670
|
-
type: "user_message",
|
|
7671
|
-
text: userMessage,
|
|
7672
|
-
hidden: hidden || void 0,
|
|
7673
|
-
// Include attachments so the live event can render a queued voice/image/file
|
|
7674
|
-
// bubble; a voice message has empty text and the transcript lives here.
|
|
7675
|
-
...hasAttachments && { attachments }
|
|
7676
|
-
});
|
|
7677
7683
|
const isFirstMessage = state.messages.filter((m) => m.role === "user").length === 1;
|
|
7678
7684
|
const STATUS_EXCLUDED_TOOLS = /* @__PURE__ */ new Set([
|
|
7679
7685
|
"setProjectOnboardingState",
|
|
@@ -7742,11 +7748,13 @@ async function runTurn(params) {
|
|
|
7742
7748
|
if (onboardingState && onboardingState !== "onboardingFinished") {
|
|
7743
7749
|
parts.push(`Build phase: ${onboardingState}`);
|
|
7744
7750
|
}
|
|
7745
|
-
const
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
|
|
7751
|
+
for (const entry of keptEntries) {
|
|
7752
|
+
const automated = parseSentinel(entry.text);
|
|
7753
|
+
if (automated) {
|
|
7754
|
+
parts.push(`Automated action: ${automated.name}`);
|
|
7755
|
+
} else if (entry.text) {
|
|
7756
|
+
parts.push(`User request: ${entry.text.slice(-500)}`);
|
|
7757
|
+
}
|
|
7750
7758
|
}
|
|
7751
7759
|
return parts.join("\n");
|
|
7752
7760
|
},
|
|
@@ -8291,25 +8299,24 @@ async function persistAttachments(attachments) {
|
|
|
8291
8299
|
images: settled.filter((s) => s.isImage).map((s) => s.result)
|
|
8292
8300
|
};
|
|
8293
8301
|
}
|
|
8294
|
-
function buildUploadHeader(
|
|
8295
|
-
const
|
|
8296
|
-
|
|
8302
|
+
function buildUploadHeader(documents, images) {
|
|
8303
|
+
const entries = [
|
|
8304
|
+
...documents.filter((r) => r !== null).map((r) => ({ ...r, isImage: false })),
|
|
8305
|
+
...images.filter((r) => r !== null).map((r) => ({ ...r, isImage: true }))
|
|
8306
|
+
];
|
|
8307
|
+
if (entries.length === 0) {
|
|
8297
8308
|
return "";
|
|
8298
8309
|
}
|
|
8299
|
-
|
|
8300
|
-
|
|
8301
|
-
const
|
|
8302
|
-
|
|
8303
|
-
|
|
8304
|
-
}
|
|
8305
|
-
return parts.join(" \u2014 ") + "]";
|
|
8310
|
+
const detail = (e) => e.extractedTextPath ? `extracted text: ${e.extractedTextPath}` : e.isImage ? null : "no extracted text \u2014 raw file only";
|
|
8311
|
+
if (entries.length === 1) {
|
|
8312
|
+
const e = entries[0];
|
|
8313
|
+
const extra = detail(e);
|
|
8314
|
+
return `[Uploaded file: ${e.localPath}${extra ? ` \u2014 ${extra}` : ""}]`;
|
|
8306
8315
|
}
|
|
8307
|
-
const lines =
|
|
8308
|
-
const
|
|
8309
|
-
|
|
8310
|
-
|
|
8311
|
-
}
|
|
8312
|
-
return parts.join("\n");
|
|
8316
|
+
const lines = entries.map((e) => {
|
|
8317
|
+
const extra = detail(e);
|
|
8318
|
+
return `- ${e.localPath}${extra ? `
|
|
8319
|
+
${extra}` : ""}`;
|
|
8313
8320
|
});
|
|
8314
8321
|
return `[Uploaded files]
|
|
8315
8322
|
${lines.join("\n")}`;
|
|
@@ -8395,6 +8402,17 @@ var MessageQueue = class {
|
|
|
8395
8402
|
}
|
|
8396
8403
|
return item;
|
|
8397
8404
|
}
|
|
8405
|
+
/** Remove and return the first `n` items; fires onChange once. */
|
|
8406
|
+
shiftMany(n) {
|
|
8407
|
+
if (n <= 0) {
|
|
8408
|
+
return [];
|
|
8409
|
+
}
|
|
8410
|
+
const items = this.items.splice(0, n);
|
|
8411
|
+
if (items.length > 0) {
|
|
8412
|
+
this.onChange?.();
|
|
8413
|
+
}
|
|
8414
|
+
return items;
|
|
8415
|
+
}
|
|
8398
8416
|
/** Remove and return all queued items. */
|
|
8399
8417
|
drain() {
|
|
8400
8418
|
if (this.items.length === 0) {
|
|
@@ -8430,6 +8448,10 @@ var MessageQueue = class {
|
|
|
8430
8448
|
peek() {
|
|
8431
8449
|
return this.items[0];
|
|
8432
8450
|
}
|
|
8451
|
+
/** Return the item at index `i` without removing it. */
|
|
8452
|
+
peekAt(i) {
|
|
8453
|
+
return this.items[i];
|
|
8454
|
+
}
|
|
8433
8455
|
get length() {
|
|
8434
8456
|
return this.items.length;
|
|
8435
8457
|
}
|
|
@@ -8512,8 +8534,12 @@ var HeadlessSession = class {
|
|
|
8512
8534
|
currentAbort = null;
|
|
8513
8535
|
/** RequestId of the in-flight message command — injected into streamed events. */
|
|
8514
8536
|
currentRequestId;
|
|
8515
|
-
/** Guard: track whether terminal `completed` was already sent so we emit
|
|
8537
|
+
/** Guard: track whether terminal `completed` was already sent so we emit
|
|
8538
|
+
* exactly one per requestId. */
|
|
8516
8539
|
completedEmitted = false;
|
|
8540
|
+
/** Outcome of the current turn's primary `completed` — read after the turn
|
|
8541
|
+
* to stamp the same success/error onto absorbed requestIds' terminals. */
|
|
8542
|
+
lastCompleted = null;
|
|
8517
8543
|
turnStart = 0;
|
|
8518
8544
|
/**
|
|
8519
8545
|
* Onboarding state of the currently-running turn. Captured at runSingleTurn
|
|
@@ -8649,6 +8675,15 @@ var HeadlessSession = class {
|
|
|
8649
8675
|
emitCompleted(rid, data) {
|
|
8650
8676
|
this.emit("completed", { ...data }, rid);
|
|
8651
8677
|
this.completedEmitted = true;
|
|
8678
|
+
this.lastCompleted = {
|
|
8679
|
+
success: data.success === true,
|
|
8680
|
+
...typeof data.error === "string" && { error: data.error }
|
|
8681
|
+
};
|
|
8682
|
+
}
|
|
8683
|
+
/** Outcome of the turn's primary `completed`, for stamping onto absorbed
|
|
8684
|
+
* requestIds' terminals. Falls back to failure if none was emitted. */
|
|
8685
|
+
primaryOutcome() {
|
|
8686
|
+
return this.lastCompleted ?? { success: false };
|
|
8652
8687
|
}
|
|
8653
8688
|
/** Dispatch a simple (non-streaming) command: call handler, emit response + completed. */
|
|
8654
8689
|
dispatchSimple(requestId, eventName, handler) {
|
|
@@ -8807,9 +8842,14 @@ var HeadlessSession = class {
|
|
|
8807
8842
|
text: e.text,
|
|
8808
8843
|
// Forward attachments so queued voice/image/file sends render live;
|
|
8809
8844
|
// otherwise the bubble is blank until a get_history refresh.
|
|
8810
|
-
...e.attachments && { attachments: e.attachments }
|
|
8845
|
+
...e.attachments && { attachments: e.attachments },
|
|
8846
|
+
// Queue-delivered entries are flagged so the frontend renders the
|
|
8847
|
+
// echo (idle sends are rendered optimistically instead).
|
|
8848
|
+
...e.queued && { queued: true }
|
|
8811
8849
|
},
|
|
8812
|
-
|
|
8850
|
+
// A merged turn emits one user_message per absorbed entry — each
|
|
8851
|
+
// carries its own original requestId, not the turn's.
|
|
8852
|
+
e.requestId ?? rid
|
|
8813
8853
|
);
|
|
8814
8854
|
return;
|
|
8815
8855
|
// Terminal events — translate to `completed`.
|
|
@@ -8832,11 +8872,9 @@ var HeadlessSession = class {
|
|
|
8832
8872
|
durationMs: Date.now() - this.turnStart
|
|
8833
8873
|
});
|
|
8834
8874
|
return;
|
|
8835
|
-
case "turn_cancelled":
|
|
8836
|
-
this.
|
|
8837
|
-
this.completedEmitted = true;
|
|
8875
|
+
case "turn_cancelled":
|
|
8876
|
+
this.emitCompleted(rid, { success: false, error: "cancelled" });
|
|
8838
8877
|
return;
|
|
8839
|
-
}
|
|
8840
8878
|
// Streaming events — forward with requestId
|
|
8841
8879
|
case "text":
|
|
8842
8880
|
this.emit(
|
|
@@ -8956,17 +8994,34 @@ var HeadlessSession = class {
|
|
|
8956
8994
|
// Message command handler (long-running / streaming)
|
|
8957
8995
|
//////////////////////////////////////////////////////////////////////////////
|
|
8958
8996
|
/**
|
|
8959
|
-
*
|
|
8960
|
-
*
|
|
8961
|
-
*
|
|
8962
|
-
*
|
|
8997
|
+
* Persist one entry's non-voice uploads to disk and build its header. The
|
|
8998
|
+
* header tells the LLM where to read each file; it's kept separate so it
|
|
8999
|
+
* gets injected at API-send time and never persisted into the user's chat
|
|
9000
|
+
* content (which would leak into history restore on the frontend).
|
|
9001
|
+
*
|
|
9002
|
+
* Must be awaited sequentially across entries — persistAttachments'
|
|
9003
|
+
* filename de-dup set is per-call, so parallel calls race on names.
|
|
8963
9004
|
*/
|
|
8964
|
-
async
|
|
8965
|
-
|
|
8966
|
-
|
|
8967
|
-
|
|
8968
|
-
|
|
8969
|
-
|
|
9005
|
+
async persistEntryAttachments(attachments) {
|
|
9006
|
+
if (!attachments?.some((a) => !a.isVoice)) {
|
|
9007
|
+
return void 0;
|
|
9008
|
+
}
|
|
9009
|
+
try {
|
|
9010
|
+
const { documents, images } = await persistAttachments(attachments);
|
|
9011
|
+
return buildUploadHeader(documents, images) || void 0;
|
|
9012
|
+
} catch (err) {
|
|
9013
|
+
log16.warn("Attachment persistence failed", { error: err.message });
|
|
9014
|
+
return void 0;
|
|
9015
|
+
}
|
|
9016
|
+
}
|
|
9017
|
+
/**
|
|
9018
|
+
* Run one turn for a single command (without acquiring the `running` lock).
|
|
9019
|
+
* Owns the per-command machinery: @@automated:: action resolution, plan-file
|
|
9020
|
+
* and buildModel side effects, and chain expansion — which is why
|
|
9021
|
+
* sentinel-bearing commands always come through here, one turn each, never
|
|
9022
|
+
* merged. The turn itself runs in executeTurn.
|
|
9023
|
+
*/
|
|
9024
|
+
async runSingleTurn(parsed, requestId, fromChain = false, queued = false) {
|
|
8970
9025
|
const attachments = parsed.attachments;
|
|
8971
9026
|
if (attachments?.length) {
|
|
8972
9027
|
log16.info("Message has attachments", {
|
|
@@ -8975,19 +9030,7 @@ var HeadlessSession = class {
|
|
|
8975
9030
|
});
|
|
8976
9031
|
}
|
|
8977
9032
|
let userMessage = parsed.text ?? "";
|
|
8978
|
-
|
|
8979
|
-
if (attachments?.some((a) => !a.isVoice)) {
|
|
8980
|
-
try {
|
|
8981
|
-
const { documents, images } = await persistAttachments(attachments);
|
|
8982
|
-
const all = [...documents, ...images];
|
|
8983
|
-
const header = buildUploadHeader(all);
|
|
8984
|
-
if (header) {
|
|
8985
|
-
attachmentHeader = header;
|
|
8986
|
-
}
|
|
8987
|
-
} catch (err) {
|
|
8988
|
-
log16.warn("Attachment persistence failed", { error: err.message });
|
|
8989
|
-
}
|
|
8990
|
-
}
|
|
9033
|
+
const attachmentHeader = await this.persistEntryAttachments(attachments);
|
|
8991
9034
|
let resolved = null;
|
|
8992
9035
|
try {
|
|
8993
9036
|
resolved = resolveAction(userMessage);
|
|
@@ -9024,22 +9067,108 @@ var HeadlessSession = class {
|
|
|
9024
9067
|
});
|
|
9025
9068
|
}
|
|
9026
9069
|
}
|
|
9070
|
+
await this.executeTurn({
|
|
9071
|
+
entries: [
|
|
9072
|
+
{
|
|
9073
|
+
text: userMessage,
|
|
9074
|
+
attachments,
|
|
9075
|
+
attachmentHeader,
|
|
9076
|
+
hidden: isHidden || void 0,
|
|
9077
|
+
requestId,
|
|
9078
|
+
queued: queued || void 0
|
|
9079
|
+
}
|
|
9080
|
+
],
|
|
9081
|
+
requestId,
|
|
9082
|
+
absorbedRids: [],
|
|
9083
|
+
onboardingState,
|
|
9084
|
+
system,
|
|
9085
|
+
buildModel
|
|
9086
|
+
});
|
|
9087
|
+
}
|
|
9088
|
+
/**
|
|
9089
|
+
* Run a mailbox batch — contiguous queued user + background items — as one
|
|
9090
|
+
* merged turn. Every item becomes its own history entry and user_message
|
|
9091
|
+
* event (own requestId, attachments, hidden flag); adjacent background
|
|
9092
|
+
* items fold into a single background_results entry so the LLM sees one
|
|
9093
|
+
* combined block. No action-sentinel machinery here: batch construction
|
|
9094
|
+
* guarantees none (sentinel-bearing user items are drain barriers that run
|
|
9095
|
+
* alone via runSingleTurn, and background_results is a NON_ACTION sentinel
|
|
9096
|
+
* with no side effects).
|
|
9097
|
+
*/
|
|
9098
|
+
async runMergedTurn(batch) {
|
|
9099
|
+
const primaryRid = batch[0].command.requestId ?? (batch.every((b) => b.source === "background") ? `background-${Date.now()}` : `merged-${Date.now()}`);
|
|
9100
|
+
const absorbedRids = batch.slice(1).map((b) => b.command.requestId).filter((rid) => typeof rid === "string");
|
|
9101
|
+
const entryList = [];
|
|
9102
|
+
for (const item of batch) {
|
|
9103
|
+
const text = item.command.text ?? "";
|
|
9104
|
+
const prev = entryList[entryList.length - 1];
|
|
9105
|
+
if (item.source === "background" && prev?.background) {
|
|
9106
|
+
prev.entry.text = mergeBackgroundResultsMessages([
|
|
9107
|
+
prev.entry.text,
|
|
9108
|
+
text
|
|
9109
|
+
]);
|
|
9110
|
+
continue;
|
|
9111
|
+
}
|
|
9112
|
+
entryList.push({
|
|
9113
|
+
background: item.source === "background",
|
|
9114
|
+
entry: {
|
|
9115
|
+
text,
|
|
9116
|
+
attachments: item.command.attachments,
|
|
9117
|
+
hidden: !!item.command.hidden || void 0,
|
|
9118
|
+
// Background items carry no requestId — their user_message falls
|
|
9119
|
+
// back to the turn's primary rid on the wire.
|
|
9120
|
+
requestId: item.command.requestId,
|
|
9121
|
+
queued: true
|
|
9122
|
+
}
|
|
9123
|
+
});
|
|
9124
|
+
}
|
|
9125
|
+
const entries = entryList.map((e) => e.entry);
|
|
9126
|
+
for (const entry of entries) {
|
|
9127
|
+
entry.attachmentHeader = await this.persistEntryAttachments(
|
|
9128
|
+
entry.attachments
|
|
9129
|
+
);
|
|
9130
|
+
}
|
|
9131
|
+
const onboardingState = batch.find((b) => b.command.onboardingState !== void 0)?.command.onboardingState ?? this.currentOnboardingState ?? "onboardingFinished";
|
|
9132
|
+
this.currentOnboardingState = onboardingState;
|
|
9133
|
+
const viewContext = [...batch].reverse().find((b) => b.command.viewContext !== void 0)?.command.viewContext;
|
|
9134
|
+
await this.executeTurn({
|
|
9135
|
+
entries,
|
|
9136
|
+
requestId: primaryRid,
|
|
9137
|
+
absorbedRids,
|
|
9138
|
+
onboardingState,
|
|
9139
|
+
system: buildSystemPrompt(onboardingState, viewContext)
|
|
9140
|
+
});
|
|
9141
|
+
}
|
|
9142
|
+
/**
|
|
9143
|
+
* Run one agent turn over the given entries (without acquiring the
|
|
9144
|
+
* `running` lock). Owns the turn-generic lifecycle: request bookkeeping,
|
|
9145
|
+
* the forced-compaction gate, runTurn error handling, and terminal
|
|
9146
|
+
* `completed` events — the primary requestId's completed first, then one
|
|
9147
|
+
* `{absorbed: true}` completed per other absorbed requestId with the same
|
|
9148
|
+
* outcome, on every exit path (done, cancel, error, unexpected), so every
|
|
9149
|
+
* queued message's caller resolves.
|
|
9150
|
+
*/
|
|
9151
|
+
async executeTurn(params) {
|
|
9152
|
+
const { entries, requestId, absorbedRids, onboardingState, system } = params;
|
|
9153
|
+
this.currentRequestId = requestId;
|
|
9154
|
+
this.currentAbort = new AbortController();
|
|
9155
|
+
this.completedEmitted = false;
|
|
9156
|
+
this.lastCompleted = null;
|
|
9157
|
+
this.turnStart = Date.now();
|
|
9158
|
+
await this.runForcedCompactionIfNeeded(requestId);
|
|
9027
9159
|
try {
|
|
9028
9160
|
await runTurn({
|
|
9029
9161
|
state: this.state,
|
|
9030
|
-
|
|
9031
|
-
attachments,
|
|
9032
|
-
attachmentHeader,
|
|
9162
|
+
entries,
|
|
9033
9163
|
apiConfig: this.config,
|
|
9034
9164
|
system,
|
|
9035
9165
|
model: this.opts.model,
|
|
9036
|
-
buildModel,
|
|
9166
|
+
buildModel: params.buildModel,
|
|
9037
9167
|
onboardingState,
|
|
9038
9168
|
requestId,
|
|
9039
9169
|
signal: this.currentAbort.signal,
|
|
9040
9170
|
onEvent: this.onEvent,
|
|
9041
9171
|
resolveExternalTool: this.resolveExternalTool,
|
|
9042
|
-
hidden: isHidden,
|
|
9043
9172
|
toolRegistry: this.toolRegistry,
|
|
9044
9173
|
onBackgroundComplete: this.onBackgroundComplete
|
|
9045
9174
|
});
|
|
@@ -9067,6 +9196,18 @@ var HeadlessSession = class {
|
|
|
9067
9196
|
error: err.message
|
|
9068
9197
|
});
|
|
9069
9198
|
}
|
|
9199
|
+
const outcome = this.primaryOutcome();
|
|
9200
|
+
for (const rid of absorbedRids) {
|
|
9201
|
+
this.emit(
|
|
9202
|
+
"completed",
|
|
9203
|
+
{
|
|
9204
|
+
success: outcome.success,
|
|
9205
|
+
...outcome.error && { error: outcome.error },
|
|
9206
|
+
absorbed: true
|
|
9207
|
+
},
|
|
9208
|
+
rid
|
|
9209
|
+
);
|
|
9210
|
+
}
|
|
9070
9211
|
applyPendingSummaries(this.state);
|
|
9071
9212
|
this.applyPendingBlockUpdates();
|
|
9072
9213
|
}
|
|
@@ -9093,42 +9234,61 @@ var HeadlessSession = class {
|
|
|
9093
9234
|
this.running = false;
|
|
9094
9235
|
}
|
|
9095
9236
|
}
|
|
9237
|
+
/**
|
|
9238
|
+
* True for queued user items whose text is an @@automated:: action message.
|
|
9239
|
+
* These key per-item raw-text side effects in runSingleTurn (resolveAction,
|
|
9240
|
+
* plan file, buildModel, chain expansion) — they always run alone, one turn
|
|
9241
|
+
* each. Background items are sentinel-formatted too but background_results
|
|
9242
|
+
* is NON_ACTION and side-effect-free, so they merge freely.
|
|
9243
|
+
*/
|
|
9244
|
+
isDrainBarrier(item) {
|
|
9245
|
+
return item.source === "user" && isAutomatedMessage(item.command.text ?? "");
|
|
9246
|
+
}
|
|
9096
9247
|
/**
|
|
9097
9248
|
* Drain the queue in strict FIFO order. Caller must hold `running = true`.
|
|
9098
9249
|
* User messages arriving during the drain will be enqueued behind current items.
|
|
9099
9250
|
*
|
|
9100
|
-
*
|
|
9101
|
-
*
|
|
9102
|
-
*
|
|
9251
|
+
* The queue serves two purposes with opposite delivery semantics:
|
|
9252
|
+
* - Sequencer: chain steps and sentinel-bearing user items are pipeline
|
|
9253
|
+
* stages — one item, one turn, nothing merged in.
|
|
9254
|
+
* - Mailbox: plain user messages and background results are accumulated
|
|
9255
|
+
* context and intent — everything contiguous flushes together into ONE
|
|
9256
|
+
* merged turn, so the model reconciles all of it at once instead of
|
|
9257
|
+
* burning a full turn per item (and possibly executing instructions a
|
|
9258
|
+
* later queued message already amended).
|
|
9103
9259
|
*/
|
|
9104
9260
|
async drainQueueLoop() {
|
|
9105
|
-
while (
|
|
9106
|
-
const
|
|
9107
|
-
if (
|
|
9108
|
-
|
|
9261
|
+
while (this.queue.length > 0) {
|
|
9262
|
+
const head = this.queue.peek();
|
|
9263
|
+
if (head.source === "chain") {
|
|
9264
|
+
const item = this.queue.shift();
|
|
9265
|
+
const rid = item.command.requestId ?? `chain-${Date.now()}`;
|
|
9266
|
+
await this.runSingleTurn(item.command, rid, true);
|
|
9267
|
+
continue;
|
|
9109
9268
|
}
|
|
9110
|
-
if (
|
|
9111
|
-
const
|
|
9112
|
-
|
|
9113
|
-
|
|
9114
|
-
if (more) {
|
|
9115
|
-
batch.push(more);
|
|
9116
|
-
}
|
|
9117
|
-
}
|
|
9118
|
-
const combinedCommand = {
|
|
9119
|
-
action: "message",
|
|
9120
|
-
text: mergeBackgroundResultsMessages(
|
|
9121
|
-
batch.map((b) => b.command.text ?? "")
|
|
9122
|
-
),
|
|
9123
|
-
...this.currentOnboardingState && {
|
|
9124
|
-
onboardingState: this.currentOnboardingState
|
|
9125
|
-
}
|
|
9126
|
-
};
|
|
9127
|
-
await this.runSingleTurn(combinedCommand, `background-${Date.now()}`);
|
|
9269
|
+
if (this.isDrainBarrier(head)) {
|
|
9270
|
+
const item = this.queue.shift();
|
|
9271
|
+
const rid = item.command.requestId ?? `user-${Date.now()}`;
|
|
9272
|
+
await this.runSingleTurn(item.command, rid, false, true);
|
|
9128
9273
|
continue;
|
|
9129
9274
|
}
|
|
9130
|
-
|
|
9131
|
-
|
|
9275
|
+
let n = 1;
|
|
9276
|
+
let batchOb = head.command.onboardingState;
|
|
9277
|
+
for (; ; n++) {
|
|
9278
|
+
const it = this.queue.peekAt(n);
|
|
9279
|
+
if (!it || it.source === "chain" || this.isDrainBarrier(it)) {
|
|
9280
|
+
break;
|
|
9281
|
+
}
|
|
9282
|
+
const ob = it.command.onboardingState;
|
|
9283
|
+
if (ob !== void 0 && batchOb !== void 0 && ob !== batchOb) {
|
|
9284
|
+
break;
|
|
9285
|
+
}
|
|
9286
|
+
if (ob !== void 0 && batchOb === void 0) {
|
|
9287
|
+
batchOb = ob;
|
|
9288
|
+
}
|
|
9289
|
+
}
|
|
9290
|
+
const batch = this.queue.shiftMany(n);
|
|
9291
|
+
await this.runMergedTurn(batch);
|
|
9132
9292
|
}
|
|
9133
9293
|
}
|
|
9134
9294
|
/**
|
|
@@ -9184,9 +9344,14 @@ var HeadlessSession = class {
|
|
|
9184
9344
|
* Cancel the running turn and flush the follow-ups that belonged to it
|
|
9185
9345
|
* (`chain`/`background`), while preserving `source: 'user'` items — those are
|
|
9186
9346
|
* independent user intent, not tied to the aborted run. The preserved user
|
|
9187
|
-
* messages run next: `
|
|
9347
|
+
* messages run next: `executeTurn` swallows the abort, so `handleMessage`
|
|
9188
9348
|
* falls through to `drainQueueLoop` with `running` still held. Returns the
|
|
9189
9349
|
* flushed items (for the cancel command's resume/discard UX).
|
|
9350
|
+
*
|
|
9351
|
+
* Messages already absorbed into the in-flight merged turn are NOT
|
|
9352
|
+
* preserved — they were delivered into the turn that's being cancelled and
|
|
9353
|
+
* each gets a `{cancelled, absorbed:true}` terminal. Only items still
|
|
9354
|
+
* sitting in the queue survive.
|
|
9190
9355
|
*/
|
|
9191
9356
|
handleCancel() {
|
|
9192
9357
|
if (this.currentAbort) {
|
package/dist/index.js
CHANGED
|
@@ -8091,9 +8091,7 @@ function createAgentState() {
|
|
|
8091
8091
|
async function runTurn(params) {
|
|
8092
8092
|
const {
|
|
8093
8093
|
state,
|
|
8094
|
-
|
|
8095
|
-
attachments,
|
|
8096
|
-
attachmentHeader,
|
|
8094
|
+
entries,
|
|
8097
8095
|
apiConfig,
|
|
8098
8096
|
system,
|
|
8099
8097
|
model,
|
|
@@ -8102,7 +8100,6 @@ async function runTurn(params) {
|
|
|
8102
8100
|
signal,
|
|
8103
8101
|
onEvent,
|
|
8104
8102
|
resolveExternalTool,
|
|
8105
|
-
hidden,
|
|
8106
8103
|
requestId,
|
|
8107
8104
|
toolRegistry,
|
|
8108
8105
|
onBackgroundComplete
|
|
@@ -8112,45 +8109,54 @@ async function runTurn(params) {
|
|
|
8112
8109
|
const baseline = resolveModel("parent", state.models, model);
|
|
8113
8110
|
const parentModel = buildModelOverride ?? baseline;
|
|
8114
8111
|
const modelOverride = buildModelOverride && buildModelOverride !== baseline ? { from: baseline } : void 0;
|
|
8112
|
+
const totalAttachments = entries.reduce(
|
|
8113
|
+
(n, e) => n + (e.attachments?.length ?? 0),
|
|
8114
|
+
0
|
|
8115
|
+
);
|
|
8115
8116
|
log13.info("Turn started", {
|
|
8116
8117
|
requestId,
|
|
8117
8118
|
model,
|
|
8118
8119
|
buildModel: buildModelOverride,
|
|
8119
8120
|
toolCount: tools2.length,
|
|
8120
|
-
...
|
|
8121
|
-
|
|
8122
|
-
}
|
|
8121
|
+
...entries.length > 1 && { entryCount: entries.length },
|
|
8122
|
+
...totalAttachments > 0 && { attachmentCount: totalAttachments }
|
|
8123
8123
|
});
|
|
8124
8124
|
onEvent({
|
|
8125
8125
|
type: "turn_started",
|
|
8126
8126
|
model: parentModel,
|
|
8127
8127
|
...modelOverride && { modelOverride }
|
|
8128
8128
|
});
|
|
8129
|
-
const
|
|
8130
|
-
|
|
8131
|
-
|
|
8129
|
+
const keptEntries = entries.filter(
|
|
8130
|
+
(e) => e.text.trim().length > 0 || (e.attachments?.length ?? 0) > 0
|
|
8131
|
+
);
|
|
8132
|
+
if (keptEntries.length === 0) {
|
|
8132
8133
|
onEvent({ type: "error", error: "Empty message" });
|
|
8133
8134
|
return;
|
|
8134
8135
|
}
|
|
8135
|
-
const
|
|
8136
|
-
|
|
8137
|
-
userMsg
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
8142
|
-
|
|
8143
|
-
|
|
8136
|
+
for (const entry of keptEntries) {
|
|
8137
|
+
const hasAttachments = (entry.attachments?.length ?? 0) > 0;
|
|
8138
|
+
const userMsg = { role: "user", content: entry.text };
|
|
8139
|
+
if (entry.hidden) {
|
|
8140
|
+
userMsg.hidden = true;
|
|
8141
|
+
}
|
|
8142
|
+
if (hasAttachments) {
|
|
8143
|
+
userMsg.attachments = entry.attachments;
|
|
8144
|
+
}
|
|
8145
|
+
if (entry.attachmentHeader) {
|
|
8146
|
+
userMsg.attachmentHeader = entry.attachmentHeader;
|
|
8147
|
+
}
|
|
8148
|
+
state.messages.push(userMsg);
|
|
8149
|
+
onEvent({
|
|
8150
|
+
type: "user_message",
|
|
8151
|
+
text: entry.text,
|
|
8152
|
+
hidden: entry.hidden || void 0,
|
|
8153
|
+
// Include attachments so the live event can render a queued voice/image/file
|
|
8154
|
+
// bubble; a voice message has empty text and the transcript lives here.
|
|
8155
|
+
...hasAttachments && { attachments: entry.attachments },
|
|
8156
|
+
...entry.requestId && { requestId: entry.requestId },
|
|
8157
|
+
...entry.queued && { queued: true }
|
|
8158
|
+
});
|
|
8144
8159
|
}
|
|
8145
|
-
state.messages.push(userMsg);
|
|
8146
|
-
onEvent({
|
|
8147
|
-
type: "user_message",
|
|
8148
|
-
text: userMessage,
|
|
8149
|
-
hidden: hidden || void 0,
|
|
8150
|
-
// Include attachments so the live event can render a queued voice/image/file
|
|
8151
|
-
// bubble; a voice message has empty text and the transcript lives here.
|
|
8152
|
-
...hasAttachments && { attachments }
|
|
8153
|
-
});
|
|
8154
8160
|
const isFirstMessage = state.messages.filter((m) => m.role === "user").length === 1;
|
|
8155
8161
|
const STATUS_EXCLUDED_TOOLS = /* @__PURE__ */ new Set([
|
|
8156
8162
|
"setProjectOnboardingState",
|
|
@@ -8219,11 +8225,13 @@ async function runTurn(params) {
|
|
|
8219
8225
|
if (onboardingState && onboardingState !== "onboardingFinished") {
|
|
8220
8226
|
parts.push(`Build phase: ${onboardingState}`);
|
|
8221
8227
|
}
|
|
8222
|
-
const
|
|
8223
|
-
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8228
|
+
for (const entry of keptEntries) {
|
|
8229
|
+
const automated = parseSentinel(entry.text);
|
|
8230
|
+
if (automated) {
|
|
8231
|
+
parts.push(`Automated action: ${automated.name}`);
|
|
8232
|
+
} else if (entry.text) {
|
|
8233
|
+
parts.push(`User request: ${entry.text.slice(-500)}`);
|
|
8234
|
+
}
|
|
8227
8235
|
}
|
|
8228
8236
|
return parts.join("\n");
|
|
8229
8237
|
},
|
|
@@ -9144,25 +9152,24 @@ async function persistAttachments(attachments) {
|
|
|
9144
9152
|
images: settled.filter((s) => s.isImage).map((s) => s.result)
|
|
9145
9153
|
};
|
|
9146
9154
|
}
|
|
9147
|
-
function buildUploadHeader(
|
|
9148
|
-
const
|
|
9149
|
-
|
|
9155
|
+
function buildUploadHeader(documents, images) {
|
|
9156
|
+
const entries = [
|
|
9157
|
+
...documents.filter((r) => r !== null).map((r) => ({ ...r, isImage: false })),
|
|
9158
|
+
...images.filter((r) => r !== null).map((r) => ({ ...r, isImage: true }))
|
|
9159
|
+
];
|
|
9160
|
+
if (entries.length === 0) {
|
|
9150
9161
|
return "";
|
|
9151
9162
|
}
|
|
9152
|
-
|
|
9153
|
-
|
|
9154
|
-
const
|
|
9155
|
-
|
|
9156
|
-
|
|
9157
|
-
}
|
|
9158
|
-
return parts.join(" \u2014 ") + "]";
|
|
9163
|
+
const detail = (e) => e.extractedTextPath ? `extracted text: ${e.extractedTextPath}` : e.isImage ? null : "no extracted text \u2014 raw file only";
|
|
9164
|
+
if (entries.length === 1) {
|
|
9165
|
+
const e = entries[0];
|
|
9166
|
+
const extra = detail(e);
|
|
9167
|
+
return `[Uploaded file: ${e.localPath}${extra ? ` \u2014 ${extra}` : ""}]`;
|
|
9159
9168
|
}
|
|
9160
|
-
const lines =
|
|
9161
|
-
const
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
}
|
|
9165
|
-
return parts.join("\n");
|
|
9169
|
+
const lines = entries.map((e) => {
|
|
9170
|
+
const extra = detail(e);
|
|
9171
|
+
return `- ${e.localPath}${extra ? `
|
|
9172
|
+
${extra}` : ""}`;
|
|
9166
9173
|
});
|
|
9167
9174
|
return `[Uploaded files]
|
|
9168
9175
|
${lines.join("\n")}`;
|
|
@@ -9275,6 +9282,17 @@ var init_messageQueue = __esm({
|
|
|
9275
9282
|
}
|
|
9276
9283
|
return item;
|
|
9277
9284
|
}
|
|
9285
|
+
/** Remove and return the first `n` items; fires onChange once. */
|
|
9286
|
+
shiftMany(n) {
|
|
9287
|
+
if (n <= 0) {
|
|
9288
|
+
return [];
|
|
9289
|
+
}
|
|
9290
|
+
const items = this.items.splice(0, n);
|
|
9291
|
+
if (items.length > 0) {
|
|
9292
|
+
this.onChange?.();
|
|
9293
|
+
}
|
|
9294
|
+
return items;
|
|
9295
|
+
}
|
|
9278
9296
|
/** Remove and return all queued items. */
|
|
9279
9297
|
drain() {
|
|
9280
9298
|
if (this.items.length === 0) {
|
|
@@ -9310,6 +9328,10 @@ var init_messageQueue = __esm({
|
|
|
9310
9328
|
peek() {
|
|
9311
9329
|
return this.items[0];
|
|
9312
9330
|
}
|
|
9331
|
+
/** Return the item at index `i` without removing it. */
|
|
9332
|
+
peekAt(i) {
|
|
9333
|
+
return this.items[i];
|
|
9334
|
+
}
|
|
9313
9335
|
get length() {
|
|
9314
9336
|
return this.items.length;
|
|
9315
9337
|
}
|
|
@@ -9427,8 +9449,12 @@ var init_headless = __esm({
|
|
|
9427
9449
|
currentAbort = null;
|
|
9428
9450
|
/** RequestId of the in-flight message command — injected into streamed events. */
|
|
9429
9451
|
currentRequestId;
|
|
9430
|
-
/** Guard: track whether terminal `completed` was already sent so we emit
|
|
9452
|
+
/** Guard: track whether terminal `completed` was already sent so we emit
|
|
9453
|
+
* exactly one per requestId. */
|
|
9431
9454
|
completedEmitted = false;
|
|
9455
|
+
/** Outcome of the current turn's primary `completed` — read after the turn
|
|
9456
|
+
* to stamp the same success/error onto absorbed requestIds' terminals. */
|
|
9457
|
+
lastCompleted = null;
|
|
9432
9458
|
turnStart = 0;
|
|
9433
9459
|
/**
|
|
9434
9460
|
* Onboarding state of the currently-running turn. Captured at runSingleTurn
|
|
@@ -9564,6 +9590,15 @@ var init_headless = __esm({
|
|
|
9564
9590
|
emitCompleted(rid, data) {
|
|
9565
9591
|
this.emit("completed", { ...data }, rid);
|
|
9566
9592
|
this.completedEmitted = true;
|
|
9593
|
+
this.lastCompleted = {
|
|
9594
|
+
success: data.success === true,
|
|
9595
|
+
...typeof data.error === "string" && { error: data.error }
|
|
9596
|
+
};
|
|
9597
|
+
}
|
|
9598
|
+
/** Outcome of the turn's primary `completed`, for stamping onto absorbed
|
|
9599
|
+
* requestIds' terminals. Falls back to failure if none was emitted. */
|
|
9600
|
+
primaryOutcome() {
|
|
9601
|
+
return this.lastCompleted ?? { success: false };
|
|
9567
9602
|
}
|
|
9568
9603
|
/** Dispatch a simple (non-streaming) command: call handler, emit response + completed. */
|
|
9569
9604
|
dispatchSimple(requestId, eventName, handler) {
|
|
@@ -9722,9 +9757,14 @@ var init_headless = __esm({
|
|
|
9722
9757
|
text: e.text,
|
|
9723
9758
|
// Forward attachments so queued voice/image/file sends render live;
|
|
9724
9759
|
// otherwise the bubble is blank until a get_history refresh.
|
|
9725
|
-
...e.attachments && { attachments: e.attachments }
|
|
9760
|
+
...e.attachments && { attachments: e.attachments },
|
|
9761
|
+
// Queue-delivered entries are flagged so the frontend renders the
|
|
9762
|
+
// echo (idle sends are rendered optimistically instead).
|
|
9763
|
+
...e.queued && { queued: true }
|
|
9726
9764
|
},
|
|
9727
|
-
|
|
9765
|
+
// A merged turn emits one user_message per absorbed entry — each
|
|
9766
|
+
// carries its own original requestId, not the turn's.
|
|
9767
|
+
e.requestId ?? rid
|
|
9728
9768
|
);
|
|
9729
9769
|
return;
|
|
9730
9770
|
// Terminal events — translate to `completed`.
|
|
@@ -9747,11 +9787,9 @@ var init_headless = __esm({
|
|
|
9747
9787
|
durationMs: Date.now() - this.turnStart
|
|
9748
9788
|
});
|
|
9749
9789
|
return;
|
|
9750
|
-
case "turn_cancelled":
|
|
9751
|
-
this.
|
|
9752
|
-
this.completedEmitted = true;
|
|
9790
|
+
case "turn_cancelled":
|
|
9791
|
+
this.emitCompleted(rid, { success: false, error: "cancelled" });
|
|
9753
9792
|
return;
|
|
9754
|
-
}
|
|
9755
9793
|
// Streaming events — forward with requestId
|
|
9756
9794
|
case "text":
|
|
9757
9795
|
this.emit(
|
|
@@ -9871,17 +9909,34 @@ var init_headless = __esm({
|
|
|
9871
9909
|
// Message command handler (long-running / streaming)
|
|
9872
9910
|
//////////////////////////////////////////////////////////////////////////////
|
|
9873
9911
|
/**
|
|
9874
|
-
*
|
|
9875
|
-
*
|
|
9876
|
-
*
|
|
9877
|
-
*
|
|
9912
|
+
* Persist one entry's non-voice uploads to disk and build its header. The
|
|
9913
|
+
* header tells the LLM where to read each file; it's kept separate so it
|
|
9914
|
+
* gets injected at API-send time and never persisted into the user's chat
|
|
9915
|
+
* content (which would leak into history restore on the frontend).
|
|
9916
|
+
*
|
|
9917
|
+
* Must be awaited sequentially across entries — persistAttachments'
|
|
9918
|
+
* filename de-dup set is per-call, so parallel calls race on names.
|
|
9878
9919
|
*/
|
|
9879
|
-
async
|
|
9880
|
-
|
|
9881
|
-
|
|
9882
|
-
|
|
9883
|
-
|
|
9884
|
-
|
|
9920
|
+
async persistEntryAttachments(attachments) {
|
|
9921
|
+
if (!attachments?.some((a) => !a.isVoice)) {
|
|
9922
|
+
return void 0;
|
|
9923
|
+
}
|
|
9924
|
+
try {
|
|
9925
|
+
const { documents, images } = await persistAttachments(attachments);
|
|
9926
|
+
return buildUploadHeader(documents, images) || void 0;
|
|
9927
|
+
} catch (err) {
|
|
9928
|
+
log16.warn("Attachment persistence failed", { error: err.message });
|
|
9929
|
+
return void 0;
|
|
9930
|
+
}
|
|
9931
|
+
}
|
|
9932
|
+
/**
|
|
9933
|
+
* Run one turn for a single command (without acquiring the `running` lock).
|
|
9934
|
+
* Owns the per-command machinery: @@automated:: action resolution, plan-file
|
|
9935
|
+
* and buildModel side effects, and chain expansion — which is why
|
|
9936
|
+
* sentinel-bearing commands always come through here, one turn each, never
|
|
9937
|
+
* merged. The turn itself runs in executeTurn.
|
|
9938
|
+
*/
|
|
9939
|
+
async runSingleTurn(parsed, requestId, fromChain = false, queued = false) {
|
|
9885
9940
|
const attachments = parsed.attachments;
|
|
9886
9941
|
if (attachments?.length) {
|
|
9887
9942
|
log16.info("Message has attachments", {
|
|
@@ -9890,19 +9945,7 @@ var init_headless = __esm({
|
|
|
9890
9945
|
});
|
|
9891
9946
|
}
|
|
9892
9947
|
let userMessage = parsed.text ?? "";
|
|
9893
|
-
|
|
9894
|
-
if (attachments?.some((a) => !a.isVoice)) {
|
|
9895
|
-
try {
|
|
9896
|
-
const { documents, images } = await persistAttachments(attachments);
|
|
9897
|
-
const all = [...documents, ...images];
|
|
9898
|
-
const header = buildUploadHeader(all);
|
|
9899
|
-
if (header) {
|
|
9900
|
-
attachmentHeader = header;
|
|
9901
|
-
}
|
|
9902
|
-
} catch (err) {
|
|
9903
|
-
log16.warn("Attachment persistence failed", { error: err.message });
|
|
9904
|
-
}
|
|
9905
|
-
}
|
|
9948
|
+
const attachmentHeader = await this.persistEntryAttachments(attachments);
|
|
9906
9949
|
let resolved = null;
|
|
9907
9950
|
try {
|
|
9908
9951
|
resolved = resolveAction(userMessage);
|
|
@@ -9939,22 +9982,108 @@ var init_headless = __esm({
|
|
|
9939
9982
|
});
|
|
9940
9983
|
}
|
|
9941
9984
|
}
|
|
9985
|
+
await this.executeTurn({
|
|
9986
|
+
entries: [
|
|
9987
|
+
{
|
|
9988
|
+
text: userMessage,
|
|
9989
|
+
attachments,
|
|
9990
|
+
attachmentHeader,
|
|
9991
|
+
hidden: isHidden || void 0,
|
|
9992
|
+
requestId,
|
|
9993
|
+
queued: queued || void 0
|
|
9994
|
+
}
|
|
9995
|
+
],
|
|
9996
|
+
requestId,
|
|
9997
|
+
absorbedRids: [],
|
|
9998
|
+
onboardingState,
|
|
9999
|
+
system,
|
|
10000
|
+
buildModel
|
|
10001
|
+
});
|
|
10002
|
+
}
|
|
10003
|
+
/**
|
|
10004
|
+
* Run a mailbox batch — contiguous queued user + background items — as one
|
|
10005
|
+
* merged turn. Every item becomes its own history entry and user_message
|
|
10006
|
+
* event (own requestId, attachments, hidden flag); adjacent background
|
|
10007
|
+
* items fold into a single background_results entry so the LLM sees one
|
|
10008
|
+
* combined block. No action-sentinel machinery here: batch construction
|
|
10009
|
+
* guarantees none (sentinel-bearing user items are drain barriers that run
|
|
10010
|
+
* alone via runSingleTurn, and background_results is a NON_ACTION sentinel
|
|
10011
|
+
* with no side effects).
|
|
10012
|
+
*/
|
|
10013
|
+
async runMergedTurn(batch) {
|
|
10014
|
+
const primaryRid = batch[0].command.requestId ?? (batch.every((b) => b.source === "background") ? `background-${Date.now()}` : `merged-${Date.now()}`);
|
|
10015
|
+
const absorbedRids = batch.slice(1).map((b) => b.command.requestId).filter((rid) => typeof rid === "string");
|
|
10016
|
+
const entryList = [];
|
|
10017
|
+
for (const item of batch) {
|
|
10018
|
+
const text = item.command.text ?? "";
|
|
10019
|
+
const prev = entryList[entryList.length - 1];
|
|
10020
|
+
if (item.source === "background" && prev?.background) {
|
|
10021
|
+
prev.entry.text = mergeBackgroundResultsMessages([
|
|
10022
|
+
prev.entry.text,
|
|
10023
|
+
text
|
|
10024
|
+
]);
|
|
10025
|
+
continue;
|
|
10026
|
+
}
|
|
10027
|
+
entryList.push({
|
|
10028
|
+
background: item.source === "background",
|
|
10029
|
+
entry: {
|
|
10030
|
+
text,
|
|
10031
|
+
attachments: item.command.attachments,
|
|
10032
|
+
hidden: !!item.command.hidden || void 0,
|
|
10033
|
+
// Background items carry no requestId — their user_message falls
|
|
10034
|
+
// back to the turn's primary rid on the wire.
|
|
10035
|
+
requestId: item.command.requestId,
|
|
10036
|
+
queued: true
|
|
10037
|
+
}
|
|
10038
|
+
});
|
|
10039
|
+
}
|
|
10040
|
+
const entries = entryList.map((e) => e.entry);
|
|
10041
|
+
for (const entry of entries) {
|
|
10042
|
+
entry.attachmentHeader = await this.persistEntryAttachments(
|
|
10043
|
+
entry.attachments
|
|
10044
|
+
);
|
|
10045
|
+
}
|
|
10046
|
+
const onboardingState = batch.find((b) => b.command.onboardingState !== void 0)?.command.onboardingState ?? this.currentOnboardingState ?? "onboardingFinished";
|
|
10047
|
+
this.currentOnboardingState = onboardingState;
|
|
10048
|
+
const viewContext = [...batch].reverse().find((b) => b.command.viewContext !== void 0)?.command.viewContext;
|
|
10049
|
+
await this.executeTurn({
|
|
10050
|
+
entries,
|
|
10051
|
+
requestId: primaryRid,
|
|
10052
|
+
absorbedRids,
|
|
10053
|
+
onboardingState,
|
|
10054
|
+
system: buildSystemPrompt(onboardingState, viewContext)
|
|
10055
|
+
});
|
|
10056
|
+
}
|
|
10057
|
+
/**
|
|
10058
|
+
* Run one agent turn over the given entries (without acquiring the
|
|
10059
|
+
* `running` lock). Owns the turn-generic lifecycle: request bookkeeping,
|
|
10060
|
+
* the forced-compaction gate, runTurn error handling, and terminal
|
|
10061
|
+
* `completed` events — the primary requestId's completed first, then one
|
|
10062
|
+
* `{absorbed: true}` completed per other absorbed requestId with the same
|
|
10063
|
+
* outcome, on every exit path (done, cancel, error, unexpected), so every
|
|
10064
|
+
* queued message's caller resolves.
|
|
10065
|
+
*/
|
|
10066
|
+
async executeTurn(params) {
|
|
10067
|
+
const { entries, requestId, absorbedRids, onboardingState, system } = params;
|
|
10068
|
+
this.currentRequestId = requestId;
|
|
10069
|
+
this.currentAbort = new AbortController();
|
|
10070
|
+
this.completedEmitted = false;
|
|
10071
|
+
this.lastCompleted = null;
|
|
10072
|
+
this.turnStart = Date.now();
|
|
10073
|
+
await this.runForcedCompactionIfNeeded(requestId);
|
|
9942
10074
|
try {
|
|
9943
10075
|
await runTurn({
|
|
9944
10076
|
state: this.state,
|
|
9945
|
-
|
|
9946
|
-
attachments,
|
|
9947
|
-
attachmentHeader,
|
|
10077
|
+
entries,
|
|
9948
10078
|
apiConfig: this.config,
|
|
9949
10079
|
system,
|
|
9950
10080
|
model: this.opts.model,
|
|
9951
|
-
buildModel,
|
|
10081
|
+
buildModel: params.buildModel,
|
|
9952
10082
|
onboardingState,
|
|
9953
10083
|
requestId,
|
|
9954
10084
|
signal: this.currentAbort.signal,
|
|
9955
10085
|
onEvent: this.onEvent,
|
|
9956
10086
|
resolveExternalTool: this.resolveExternalTool,
|
|
9957
|
-
hidden: isHidden,
|
|
9958
10087
|
toolRegistry: this.toolRegistry,
|
|
9959
10088
|
onBackgroundComplete: this.onBackgroundComplete
|
|
9960
10089
|
});
|
|
@@ -9982,6 +10111,18 @@ var init_headless = __esm({
|
|
|
9982
10111
|
error: err.message
|
|
9983
10112
|
});
|
|
9984
10113
|
}
|
|
10114
|
+
const outcome = this.primaryOutcome();
|
|
10115
|
+
for (const rid of absorbedRids) {
|
|
10116
|
+
this.emit(
|
|
10117
|
+
"completed",
|
|
10118
|
+
{
|
|
10119
|
+
success: outcome.success,
|
|
10120
|
+
...outcome.error && { error: outcome.error },
|
|
10121
|
+
absorbed: true
|
|
10122
|
+
},
|
|
10123
|
+
rid
|
|
10124
|
+
);
|
|
10125
|
+
}
|
|
9985
10126
|
applyPendingSummaries(this.state);
|
|
9986
10127
|
this.applyPendingBlockUpdates();
|
|
9987
10128
|
}
|
|
@@ -10008,42 +10149,61 @@ var init_headless = __esm({
|
|
|
10008
10149
|
this.running = false;
|
|
10009
10150
|
}
|
|
10010
10151
|
}
|
|
10152
|
+
/**
|
|
10153
|
+
* True for queued user items whose text is an @@automated:: action message.
|
|
10154
|
+
* These key per-item raw-text side effects in runSingleTurn (resolveAction,
|
|
10155
|
+
* plan file, buildModel, chain expansion) — they always run alone, one turn
|
|
10156
|
+
* each. Background items are sentinel-formatted too but background_results
|
|
10157
|
+
* is NON_ACTION and side-effect-free, so they merge freely.
|
|
10158
|
+
*/
|
|
10159
|
+
isDrainBarrier(item) {
|
|
10160
|
+
return item.source === "user" && isAutomatedMessage(item.command.text ?? "");
|
|
10161
|
+
}
|
|
10011
10162
|
/**
|
|
10012
10163
|
* Drain the queue in strict FIFO order. Caller must hold `running = true`.
|
|
10013
10164
|
* User messages arriving during the drain will be enqueued behind current items.
|
|
10014
10165
|
*
|
|
10015
|
-
*
|
|
10016
|
-
*
|
|
10017
|
-
*
|
|
10166
|
+
* The queue serves two purposes with opposite delivery semantics:
|
|
10167
|
+
* - Sequencer: chain steps and sentinel-bearing user items are pipeline
|
|
10168
|
+
* stages — one item, one turn, nothing merged in.
|
|
10169
|
+
* - Mailbox: plain user messages and background results are accumulated
|
|
10170
|
+
* context and intent — everything contiguous flushes together into ONE
|
|
10171
|
+
* merged turn, so the model reconciles all of it at once instead of
|
|
10172
|
+
* burning a full turn per item (and possibly executing instructions a
|
|
10173
|
+
* later queued message already amended).
|
|
10018
10174
|
*/
|
|
10019
10175
|
async drainQueueLoop() {
|
|
10020
|
-
while (
|
|
10021
|
-
const
|
|
10022
|
-
if (
|
|
10023
|
-
|
|
10176
|
+
while (this.queue.length > 0) {
|
|
10177
|
+
const head = this.queue.peek();
|
|
10178
|
+
if (head.source === "chain") {
|
|
10179
|
+
const item = this.queue.shift();
|
|
10180
|
+
const rid = item.command.requestId ?? `chain-${Date.now()}`;
|
|
10181
|
+
await this.runSingleTurn(item.command, rid, true);
|
|
10182
|
+
continue;
|
|
10024
10183
|
}
|
|
10025
|
-
if (
|
|
10026
|
-
const
|
|
10027
|
-
|
|
10028
|
-
|
|
10029
|
-
if (more) {
|
|
10030
|
-
batch.push(more);
|
|
10031
|
-
}
|
|
10032
|
-
}
|
|
10033
|
-
const combinedCommand = {
|
|
10034
|
-
action: "message",
|
|
10035
|
-
text: mergeBackgroundResultsMessages(
|
|
10036
|
-
batch.map((b) => b.command.text ?? "")
|
|
10037
|
-
),
|
|
10038
|
-
...this.currentOnboardingState && {
|
|
10039
|
-
onboardingState: this.currentOnboardingState
|
|
10040
|
-
}
|
|
10041
|
-
};
|
|
10042
|
-
await this.runSingleTurn(combinedCommand, `background-${Date.now()}`);
|
|
10184
|
+
if (this.isDrainBarrier(head)) {
|
|
10185
|
+
const item = this.queue.shift();
|
|
10186
|
+
const rid = item.command.requestId ?? `user-${Date.now()}`;
|
|
10187
|
+
await this.runSingleTurn(item.command, rid, false, true);
|
|
10043
10188
|
continue;
|
|
10044
10189
|
}
|
|
10045
|
-
|
|
10046
|
-
|
|
10190
|
+
let n = 1;
|
|
10191
|
+
let batchOb = head.command.onboardingState;
|
|
10192
|
+
for (; ; n++) {
|
|
10193
|
+
const it = this.queue.peekAt(n);
|
|
10194
|
+
if (!it || it.source === "chain" || this.isDrainBarrier(it)) {
|
|
10195
|
+
break;
|
|
10196
|
+
}
|
|
10197
|
+
const ob = it.command.onboardingState;
|
|
10198
|
+
if (ob !== void 0 && batchOb !== void 0 && ob !== batchOb) {
|
|
10199
|
+
break;
|
|
10200
|
+
}
|
|
10201
|
+
if (ob !== void 0 && batchOb === void 0) {
|
|
10202
|
+
batchOb = ob;
|
|
10203
|
+
}
|
|
10204
|
+
}
|
|
10205
|
+
const batch = this.queue.shiftMany(n);
|
|
10206
|
+
await this.runMergedTurn(batch);
|
|
10047
10207
|
}
|
|
10048
10208
|
}
|
|
10049
10209
|
/**
|
|
@@ -10099,9 +10259,14 @@ var init_headless = __esm({
|
|
|
10099
10259
|
* Cancel the running turn and flush the follow-ups that belonged to it
|
|
10100
10260
|
* (`chain`/`background`), while preserving `source: 'user'` items — those are
|
|
10101
10261
|
* independent user intent, not tied to the aborted run. The preserved user
|
|
10102
|
-
* messages run next: `
|
|
10262
|
+
* messages run next: `executeTurn` swallows the abort, so `handleMessage`
|
|
10103
10263
|
* falls through to `drainQueueLoop` with `running` still held. Returns the
|
|
10104
10264
|
* flushed items (for the cancel command's resume/discard UX).
|
|
10265
|
+
*
|
|
10266
|
+
* Messages already absorbed into the in-flight merged turn are NOT
|
|
10267
|
+
* preserved — they were delivered into the turn that's being cancelled and
|
|
10268
|
+
* each gets a `{cancelled, absorbed:true}` terminal. Only items still
|
|
10269
|
+
* sitting in the queue survive.
|
|
10105
10270
|
*/
|
|
10106
10271
|
handleCancel() {
|
|
10107
10272
|
if (this.currentAbort) {
|
|
@@ -10528,7 +10693,7 @@ function App({ apiConfig, model }) {
|
|
|
10528
10693
|
try {
|
|
10529
10694
|
await runTurn({
|
|
10530
10695
|
state: agentState,
|
|
10531
|
-
|
|
10696
|
+
entries: [{ text: message }],
|
|
10532
10697
|
apiConfig,
|
|
10533
10698
|
system,
|
|
10534
10699
|
model,
|
|
@@ -28,7 +28,7 @@ The user can already see your tool calls, so most of your work is visible withou
|
|
|
28
28
|
Skip the rest: narrating what you're about to do, restating what the user asked, explaining tool calls they can already see.
|
|
29
29
|
|
|
30
30
|
### User attachments
|
|
31
|
-
When a user uploads a file (PDF, Word doc, image, etc.), it is automatically saved to `src/.user-uploads/` in the project directory. The message includes the local file path, and for documents with extractable text, a `.txt` sidecar with the extracted content
|
|
31
|
+
When a user uploads a file (PDF, Word doc, image, etc.), it is automatically saved to `src/.user-uploads/` in the project directory. The message includes the local file path, and for documents with extractable text, a `.txt` sidecar with the extracted content you can read with `readFile`. A document marked "no extracted text — raw file only" has no sidecar: parse the raw file yourself, and if it is genuinely unreadable, tell the user what happened. Pass the file path itself to tools that take an image — `screenshot`, and the design expert's `analyzeImage` / `analyzeDesign` / `editImages` — and they host the file and hand back a URL you can reuse or embed in a spec. If a raw file from `src/` needs to be served by the web interface, copy it to `dist/interfaces/web/public/`. These files persist across the conversation — they survive compaction and session restarts. Do not ask the user to re-upload a document that has already been saved. Voice messages are not saved to disk — their transcripts appear inline in the message.
|
|
32
32
|
|
|
33
33
|
### Automated messages
|
|
34
34
|
You will occasionally receive automated messages prefixed with `@@automated_message@@` - these are triggered by things like background agents returning their work, or by the user clicking a button in the UI (e.g., the user might click a "Build Feature" button in the product roadmap UI, and you will receive a message detailing what they want to build). You will be able to see these messages in your chat history but the user will not see them, so acknowledge them appropriately and then perform the requested work.
|
|
@@ -71,6 +71,7 @@ Result text here...
|
|
|
71
71
|
When you receive background results:
|
|
72
72
|
- Acknowledge them briefly to the user if relevant to what they're doing (e.g., "By the way, the designer finished those icons..." or "Looks like the roadmap is ready...")
|
|
73
73
|
- Don't interrupt the user's flow with a lengthy summary — they can see the background work in the UI
|
|
74
|
+
- Background results may arrive in the same turn as new user messages. When they do, the user's messages are the priority — fold the results in where they matter and get on with what the user asked.
|
|
74
75
|
|
|
75
76
|
#### When You Are Allowed to Background
|
|
76
77
|
|