@vellumai/assistant 0.3.13 → 0.3.15
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/ARCHITECTURE.md +17 -3
- package/Dockerfile +1 -1
- package/README.md +2 -0
- package/docs/architecture/scheduling.md +81 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +22 -0
- package/src/__tests__/channel-policy.test.ts +19 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +582 -0
- package/src/__tests__/guardian-outbound-http.test.ts +8 -8
- package/src/__tests__/intent-routing.test.ts +22 -0
- package/src/__tests__/ipc-snapshot.test.ts +10 -0
- package/src/__tests__/notification-routing-intent.test.ts +185 -0
- package/src/__tests__/recording-handler.test.ts +191 -31
- package/src/__tests__/recording-intent-fallback.test.ts +180 -0
- package/src/__tests__/recording-intent-handler.test.ts +597 -74
- package/src/__tests__/recording-intent.test.ts +738 -342
- package/src/__tests__/recording-state-machine.test.ts +1109 -0
- package/src/__tests__/reminder-store.test.ts +20 -18
- package/src/__tests__/reminder.test.ts +2 -1
- package/src/channels/config.ts +1 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -11
- package/src/config/bundled-skills/screen-recording/SKILL.md +91 -12
- package/src/config/system-prompt.ts +5 -0
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/daemon/handlers/config-channels.ts +6 -6
- package/src/daemon/handlers/index.ts +1 -1
- package/src/daemon/handlers/misc.ts +258 -102
- package/src/daemon/handlers/recording.ts +417 -5
- package/src/daemon/handlers/sessions.ts +142 -68
- package/src/daemon/ipc-contract/computer-use.ts +23 -3
- package/src/daemon/ipc-contract/messages.ts +3 -1
- package/src/daemon/ipc-contract/shared.ts +6 -0
- package/src/daemon/ipc-contract-inventory.json +2 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/recording-executor.ts +180 -0
- package/src/daemon/recording-intent-fallback.ts +132 -0
- package/src/daemon/recording-intent.ts +306 -15
- package/src/daemon/session-tool-setup.ts +4 -0
- package/src/memory/conversation-attention-store.ts +5 -5
- package/src/notifications/README.md +69 -1
- package/src/notifications/adapters/sms.ts +80 -0
- package/src/notifications/broadcaster.ts +1 -0
- package/src/notifications/copy-composer.ts +3 -3
- package/src/notifications/decision-engine.ts +70 -1
- package/src/notifications/decisions-store.ts +24 -0
- package/src/notifications/destination-resolver.ts +2 -1
- package/src/notifications/emit-signal.ts +35 -3
- package/src/notifications/signal.ts +6 -0
- package/src/notifications/types.ts +3 -0
- package/src/runtime/guardian-outbound-actions.ts +9 -9
- package/src/runtime/http-server.ts +7 -7
- package/src/runtime/routes/conversation-attention-routes.ts +3 -3
- package/src/runtime/routes/integration-routes.ts +5 -5
- package/src/schedule/scheduler.ts +15 -3
- package/src/tools/executor.ts +29 -0
- package/src/tools/guardian-control-plane-policy.ts +141 -0
- package/src/tools/types.ts +2 -0
|
@@ -21,10 +21,12 @@ import type {
|
|
|
21
21
|
SuggestionRequest,
|
|
22
22
|
TaskSubmit,
|
|
23
23
|
} from '../ipc-protocol.js';
|
|
24
|
-
import {
|
|
24
|
+
import { executeRecordingIntent } from '../recording-executor.js';
|
|
25
|
+
import { resolveRecordingIntent } from '../recording-intent.js';
|
|
26
|
+
import { classifyRecordingIntentFallback, containsRecordingKeywords } from '../recording-intent-fallback.js';
|
|
25
27
|
import { buildSessionErrorMessage,classifySessionError } from '../session-error.js';
|
|
26
28
|
import { handleCuSessionCreate } from './computer-use.js';
|
|
27
|
-
import { handleRecordingStart, handleRecordingStop } from './recording.js';
|
|
29
|
+
import { handleRecordingPause, handleRecordingRestart, handleRecordingResume, handleRecordingStart, handleRecordingStop, isRecordingIdle } from './recording.js';
|
|
28
30
|
import { defineHandlers, type HandlerContext,log, renderHistoryContent, wireEscalationHandler } from './shared.js';
|
|
29
31
|
|
|
30
32
|
// ─── Task submit handler ────────────────────────────────────────────────────
|
|
@@ -70,125 +72,262 @@ export async function handleTaskSubmit(
|
|
|
70
72
|
return;
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
// ── Structured command intent (bypasses text parsing) ──────────────────
|
|
76
|
+
const config = getConfig();
|
|
77
|
+
if (config.daemon.standaloneRecording && msg.commandIntent?.domain === 'screen_recording') {
|
|
78
|
+
const action = msg.commandIntent.action;
|
|
79
|
+
rlog.info({ action, source: 'commandIntent' }, 'Recording command intent received');
|
|
80
|
+
if (action === 'start') {
|
|
81
|
+
const conversation = conversationStore.createConversation(msg.task || 'Screen Recording');
|
|
82
|
+
ctx.socketToSession.set(socket, conversation.id);
|
|
83
|
+
const recordingId = handleRecordingStart(conversation.id, { promptForSource: true }, socket, ctx);
|
|
84
|
+
ctx.send(socket, { type: 'task_routed', sessionId: conversation.id, interactionType: 'text_qa' });
|
|
85
|
+
ctx.send(socket, {
|
|
86
|
+
type: 'assistant_text_delta',
|
|
87
|
+
text: recordingId ? 'Starting screen recording.' : 'A recording is already active.',
|
|
88
|
+
sessionId: conversation.id,
|
|
89
|
+
});
|
|
90
|
+
ctx.send(socket, { type: 'message_complete', sessionId: conversation.id });
|
|
91
|
+
if (!recordingId) ctx.socketToSession.delete(socket);
|
|
92
|
+
return;
|
|
93
|
+
} else if (action === 'stop') {
|
|
94
|
+
let activeSessionId = ctx.socketToSession.get(socket);
|
|
95
|
+
if (!activeSessionId) {
|
|
96
|
+
const conversation = conversationStore.createConversation(msg.task || 'Stop Recording');
|
|
97
|
+
activeSessionId = conversation.id;
|
|
98
|
+
ctx.socketToSession.set(socket, activeSessionId);
|
|
99
|
+
}
|
|
100
|
+
const stopped = handleRecordingStop(activeSessionId, ctx) !== undefined;
|
|
101
|
+
ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
|
|
102
|
+
ctx.send(socket, {
|
|
103
|
+
type: 'assistant_text_delta',
|
|
104
|
+
text: stopped ? 'Stopping the recording.' : 'No active recording to stop.',
|
|
105
|
+
sessionId: activeSessionId,
|
|
106
|
+
});
|
|
107
|
+
ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
|
|
108
|
+
return;
|
|
109
|
+
} else if (action === 'restart') {
|
|
110
|
+
let activeSessionId = ctx.socketToSession.get(socket);
|
|
111
|
+
if (!activeSessionId) {
|
|
112
|
+
const conversation = conversationStore.createConversation(msg.task || 'Restart Recording');
|
|
113
|
+
activeSessionId = conversation.id;
|
|
114
|
+
ctx.socketToSession.set(socket, activeSessionId);
|
|
115
|
+
}
|
|
116
|
+
const restartResult = handleRecordingRestart(activeSessionId, socket, ctx);
|
|
117
|
+
ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
|
|
118
|
+
ctx.send(socket, {
|
|
119
|
+
type: 'assistant_text_delta',
|
|
120
|
+
text: restartResult.responseText,
|
|
121
|
+
sessionId: activeSessionId,
|
|
122
|
+
});
|
|
123
|
+
ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
|
|
124
|
+
return;
|
|
125
|
+
} else if (action === 'pause') {
|
|
126
|
+
let activeSessionId = ctx.socketToSession.get(socket);
|
|
127
|
+
if (!activeSessionId) {
|
|
128
|
+
const conversation = conversationStore.createConversation(msg.task || 'Pause Recording');
|
|
129
|
+
activeSessionId = conversation.id;
|
|
130
|
+
ctx.socketToSession.set(socket, activeSessionId);
|
|
131
|
+
}
|
|
132
|
+
const paused = handleRecordingPause(activeSessionId, ctx) !== undefined;
|
|
133
|
+
ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
|
|
134
|
+
ctx.send(socket, {
|
|
135
|
+
type: 'assistant_text_delta',
|
|
136
|
+
text: paused ? 'Pausing the recording.' : 'No active recording to pause.',
|
|
137
|
+
sessionId: activeSessionId,
|
|
138
|
+
});
|
|
139
|
+
ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
|
|
140
|
+
return;
|
|
141
|
+
} else if (action === 'resume') {
|
|
142
|
+
let activeSessionId = ctx.socketToSession.get(socket);
|
|
143
|
+
if (!activeSessionId) {
|
|
144
|
+
const conversation = conversationStore.createConversation(msg.task || 'Resume Recording');
|
|
145
|
+
activeSessionId = conversation.id;
|
|
146
|
+
ctx.socketToSession.set(socket, activeSessionId);
|
|
147
|
+
}
|
|
148
|
+
const resumed = handleRecordingResume(activeSessionId, ctx) !== undefined;
|
|
149
|
+
ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
|
|
150
|
+
ctx.send(socket, {
|
|
151
|
+
type: 'assistant_text_delta',
|
|
152
|
+
text: resumed ? 'Resuming the recording.' : 'No active recording to resume.',
|
|
153
|
+
sessionId: activeSessionId,
|
|
154
|
+
});
|
|
155
|
+
ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
|
|
156
|
+
return;
|
|
157
|
+
} else {
|
|
158
|
+
// Unrecognized action — fall through to normal text handling so the
|
|
159
|
+
// task is not silently dropped.
|
|
160
|
+
rlog.warn({ action, source: 'commandIntent' }, 'Unrecognized screen_recording action, falling through to text handling');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
73
164
|
// ── Standalone recording intent interception ──────────────────────────
|
|
74
|
-
// Intercept recording-only and stop-recording prompts before they reach
|
|
75
|
-
// the classifier. This prevents "record my screen" from creating a CU
|
|
76
|
-
// session and routes it to the standalone recording flow instead.
|
|
77
|
-
//
|
|
78
|
-
// For mixed intent, recording start/stop is deferred until after the
|
|
79
|
-
// downstream classifier creates the final conversation, so the recording
|
|
80
|
-
// attachment is linked to the correct conversation (not an orphaned one).
|
|
81
165
|
let pendingRecordingStart = false;
|
|
82
166
|
let pendingRecordingStop = false;
|
|
83
|
-
|
|
167
|
+
let pendingRecordingRestart: 'restart_with_remainder' | 'start_and_stop_with_remainder' | false = false;
|
|
84
168
|
if (config.daemon.standaloneRecording) {
|
|
85
169
|
const name = getAssistantName();
|
|
86
170
|
const dynamicNames = [name].filter(Boolean) as string[];
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
text: stopped ? 'Stopping the recording.' : 'No active recording to stop.',
|
|
108
|
-
sessionId: activeSessionId,
|
|
109
|
-
});
|
|
110
|
-
ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
|
|
111
|
-
return;
|
|
171
|
+
const intentResult = resolveRecordingIntent(msg.task, dynamicNames);
|
|
172
|
+
|
|
173
|
+
if (intentResult.kind === 'start_only') {
|
|
174
|
+
// Create a conversation so the recording can be attached later
|
|
175
|
+
const conversation = conversationStore.createConversation(msg.task);
|
|
176
|
+
ctx.socketToSession.set(socket, conversation.id);
|
|
177
|
+
|
|
178
|
+
const execResult = executeRecordingIntent(intentResult, {
|
|
179
|
+
conversationId: conversation.id,
|
|
180
|
+
socket,
|
|
181
|
+
ctx,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
ctx.send(socket, { type: 'task_routed', sessionId: conversation.id, interactionType: 'text_qa' });
|
|
185
|
+
ctx.send(socket, { type: 'assistant_text_delta', text: execResult.responseText!, sessionId: conversation.id });
|
|
186
|
+
ctx.send(socket, { type: 'message_complete', sessionId: conversation.id });
|
|
187
|
+
|
|
188
|
+
// If recording rejected, unbind socket
|
|
189
|
+
if (execResult.recordingStarted === false) {
|
|
190
|
+
ctx.socketToSession.delete(socket);
|
|
112
191
|
}
|
|
113
|
-
|
|
114
|
-
|
|
192
|
+
|
|
193
|
+
rlog.info({ sessionId: conversation.id }, 'Recording-only intent intercepted — routed to standalone recording');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (intentResult.kind === 'stop_only') {
|
|
198
|
+
let activeSessionId = ctx.socketToSession.get(socket);
|
|
199
|
+
if (!activeSessionId) {
|
|
115
200
|
const conversation = conversationStore.createConversation(msg.task);
|
|
116
|
-
|
|
201
|
+
activeSessionId = conversation.id;
|
|
202
|
+
ctx.socketToSession.set(socket, activeSessionId);
|
|
203
|
+
}
|
|
117
204
|
|
|
118
|
-
|
|
205
|
+
const execResult = executeRecordingIntent(intentResult, {
|
|
206
|
+
conversationId: activeSessionId,
|
|
207
|
+
socket,
|
|
208
|
+
ctx,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
rlog.info('Recording stop intent intercepted');
|
|
212
|
+
ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
|
|
213
|
+
ctx.send(socket, { type: 'assistant_text_delta', text: execResult.responseText!, sessionId: activeSessionId });
|
|
214
|
+
ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
119
217
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
ctx.send(socket, { type: 'message_complete', sessionId: conversation.id });
|
|
218
|
+
if (intentResult.kind === 'start_and_stop_only') {
|
|
219
|
+
let activeSessionId = ctx.socketToSession.get(socket);
|
|
220
|
+
if (!activeSessionId) {
|
|
221
|
+
const conversation = conversationStore.createConversation(msg.task);
|
|
222
|
+
activeSessionId = conversation.id;
|
|
223
|
+
ctx.socketToSession.set(socket, activeSessionId);
|
|
224
|
+
}
|
|
129
225
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
226
|
+
const execResult = executeRecordingIntent(intentResult, {
|
|
227
|
+
conversationId: activeSessionId,
|
|
228
|
+
socket,
|
|
229
|
+
ctx,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
rlog.info('Recording start+stop intent intercepted');
|
|
233
|
+
ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
|
|
234
|
+
ctx.send(socket, { type: 'assistant_text_delta', text: execResult.responseText!, sessionId: activeSessionId });
|
|
235
|
+
ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
136
238
|
|
|
137
|
-
|
|
138
|
-
|
|
239
|
+
// Restart/pause/resume — fully handled intents
|
|
240
|
+
if (intentResult.kind === 'restart_only' || intentResult.kind === 'pause_only' || intentResult.kind === 'resume_only') {
|
|
241
|
+
let activeSessionId = ctx.socketToSession.get(socket);
|
|
242
|
+
if (!activeSessionId) {
|
|
243
|
+
const conversation = conversationStore.createConversation(msg.task);
|
|
244
|
+
activeSessionId = conversation.id;
|
|
245
|
+
ctx.socketToSession.set(socket, activeSessionId);
|
|
139
246
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
247
|
+
|
|
248
|
+
const execResult = executeRecordingIntent(intentResult, {
|
|
249
|
+
conversationId: activeSessionId,
|
|
250
|
+
socket,
|
|
251
|
+
ctx,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
rlog.info({ kind: intentResult.kind }, 'Recording intent intercepted');
|
|
255
|
+
ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
|
|
256
|
+
ctx.send(socket, { type: 'assistant_text_delta', text: execResult.responseText!, sessionId: activeSessionId });
|
|
257
|
+
ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (intentResult.kind === 'start_with_remainder' || intentResult.kind === 'stop_with_remainder' ||
|
|
262
|
+
intentResult.kind === 'start_and_stop_with_remainder' || intentResult.kind === 'restart_with_remainder') {
|
|
263
|
+
// Defer recording action until after classifier creates the final conversation
|
|
264
|
+
pendingRecordingStop = intentResult.kind === 'stop_with_remainder';
|
|
265
|
+
// start_and_stop_with_remainder is semantically a restart — route through
|
|
266
|
+
// handleRecordingRestart which properly cleans up maps between stop and start.
|
|
267
|
+
// However, when there's no active recording the stop is a no-op, so fall
|
|
268
|
+
// back to a plain start instead of restart.
|
|
269
|
+
if (intentResult.kind === 'start_and_stop_with_remainder') {
|
|
270
|
+
if (isRecordingIdle()) {
|
|
271
|
+
pendingRecordingStart = true;
|
|
272
|
+
} else {
|
|
273
|
+
pendingRecordingRestart = 'start_and_stop_with_remainder';
|
|
146
274
|
}
|
|
275
|
+
} else {
|
|
276
|
+
pendingRecordingStart = intentResult.kind === 'start_with_remainder';
|
|
277
|
+
pendingRecordingRestart = intentResult.kind === 'restart_with_remainder' ? 'restart_with_remainder' : false;
|
|
278
|
+
}
|
|
279
|
+
(msg as { task: string }).task = intentResult.remainder;
|
|
280
|
+
rlog.info({ remaining: intentResult.remainder }, 'Recording intent deferred, continuing with remaining text');
|
|
281
|
+
}
|
|
147
282
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
283
|
+
// 'none' — deterministic resolver found nothing; try LLM fallback
|
|
284
|
+
// if the text contains recording-related keywords.
|
|
285
|
+
if (intentResult.kind === 'none' && containsRecordingKeywords(msg.task)) {
|
|
286
|
+
const fallback = await classifyRecordingIntentFallback(msg.task);
|
|
287
|
+
rlog.info({ fallbackAction: fallback.action, fallbackConfidence: fallback.confidence }, 'Recording intent LLM fallback result');
|
|
288
|
+
|
|
289
|
+
if (fallback.action !== 'none' && fallback.confidence === 'high') {
|
|
290
|
+
const kindMap: Record<string, import('../recording-intent.js').RecordingIntentResult> = {
|
|
291
|
+
start: { kind: 'start_only' },
|
|
292
|
+
stop: { kind: 'stop_only' },
|
|
293
|
+
restart: { kind: 'restart_only' },
|
|
294
|
+
pause: { kind: 'pause_only' },
|
|
295
|
+
resume: { kind: 'resume_only' },
|
|
296
|
+
};
|
|
297
|
+
const mapped = kindMap[fallback.action];
|
|
298
|
+
if (mapped) {
|
|
299
|
+
let activeSessionId = ctx.socketToSession.get(socket);
|
|
300
|
+
if (!activeSessionId) {
|
|
163
301
|
const conversation = conversationStore.createConversation(msg.task);
|
|
164
|
-
|
|
165
|
-
ctx.socketToSession.set(socket,
|
|
302
|
+
activeSessionId = conversation.id;
|
|
303
|
+
ctx.socketToSession.set(socket, activeSessionId);
|
|
166
304
|
}
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
305
|
+
|
|
306
|
+
const execResult = executeRecordingIntent(mapped, {
|
|
307
|
+
conversationId: activeSessionId,
|
|
308
|
+
socket,
|
|
309
|
+
ctx,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (execResult.handled) {
|
|
313
|
+
rlog.info({ kind: mapped.kind, source: 'llm_fallback' }, 'Recording intent intercepted via LLM fallback');
|
|
314
|
+
ctx.send(socket, { type: 'task_routed', sessionId: activeSessionId, interactionType: 'text_qa' });
|
|
315
|
+
ctx.send(socket, {
|
|
316
|
+
type: 'assistant_text_delta',
|
|
317
|
+
text: execResult.responseText!,
|
|
318
|
+
sessionId: activeSessionId,
|
|
319
|
+
});
|
|
320
|
+
ctx.send(socket, { type: 'message_complete', sessionId: activeSessionId });
|
|
321
|
+
|
|
322
|
+
// If recording was rejected (e.g. already active), unbind the
|
|
323
|
+
// socket so it doesn't stay bound to an orphaned conversation.
|
|
324
|
+
if (execResult.recordingStarted === false) {
|
|
325
|
+
ctx.socketToSession.delete(socket);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
177
328
|
}
|
|
178
|
-
ctx.send(socket, { type: 'assistant_text_delta', text, sessionId });
|
|
179
|
-
ctx.send(socket, { type: 'message_complete', sessionId });
|
|
180
|
-
return;
|
|
181
329
|
}
|
|
182
|
-
|
|
183
|
-
// Set deferred flags — recording will start after the final conversation is created
|
|
184
|
-
pendingRecordingStart = hasStart;
|
|
185
|
-
pendingRecordingStop = hasStop;
|
|
186
|
-
(msg as { task: string }).task = remaining;
|
|
187
|
-
rlog.info({ remaining }, 'Mixed recording intent — deferred, continuing with remaining text');
|
|
188
|
-
break;
|
|
189
330
|
}
|
|
190
|
-
case 'none':
|
|
191
|
-
break;
|
|
192
331
|
}
|
|
193
332
|
}
|
|
194
333
|
|
|
@@ -215,10 +354,20 @@ export async function handleTaskSubmit(
|
|
|
215
354
|
|
|
216
355
|
// Start deferred recording from mixed intent (create a DB conversation
|
|
217
356
|
// for the recording attachment since CU sessions don't have one).
|
|
218
|
-
if (pendingRecordingStart || pendingRecordingStop) {
|
|
357
|
+
if (pendingRecordingStart || pendingRecordingStop || pendingRecordingRestart) {
|
|
219
358
|
const recConversation = conversationStore.createConversation('Screen Recording');
|
|
220
359
|
if (pendingRecordingStop) handleRecordingStop(recConversation.id, ctx);
|
|
221
360
|
if (pendingRecordingStart) handleRecordingStart(recConversation.id, { promptForSource: true }, socket, ctx);
|
|
361
|
+
if (pendingRecordingRestart) {
|
|
362
|
+
const restartResult = handleRecordingRestart(recConversation.id, socket, ctx);
|
|
363
|
+
// TOCTOU: recording may have stopped between intent resolution and
|
|
364
|
+
// deferred execution. Fall back to plain start for stop-and-start
|
|
365
|
+
// intents (user wants a new recording), but not for pure restart.
|
|
366
|
+
if (!restartResult.initiated && restartResult.reason === 'no_active_recording'
|
|
367
|
+
&& pendingRecordingRestart === 'start_and_stop_with_remainder') {
|
|
368
|
+
handleRecordingStart(recConversation.id, { promptForSource: true }, socket, ctx);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
222
371
|
}
|
|
223
372
|
|
|
224
373
|
ctx.send(socket, {
|
|
@@ -243,6 +392,13 @@ export async function handleTaskSubmit(
|
|
|
243
392
|
// Start deferred recording from mixed intent, now using the real conversation
|
|
244
393
|
if (pendingRecordingStop) handleRecordingStop(conversation.id, ctx);
|
|
245
394
|
if (pendingRecordingStart) handleRecordingStart(conversation.id, { promptForSource: true }, socket, ctx);
|
|
395
|
+
if (pendingRecordingRestart) {
|
|
396
|
+
const restartResult = handleRecordingRestart(conversation.id, socket, ctx);
|
|
397
|
+
if (!restartResult.initiated && restartResult.reason === 'no_active_recording'
|
|
398
|
+
&& pendingRecordingRestart === 'start_and_stop_with_remainder') {
|
|
399
|
+
handleRecordingStart(conversation.id, { promptForSource: true }, socket, ctx);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
246
402
|
|
|
247
403
|
ctx.send(socket, {
|
|
248
404
|
type: 'task_routed',
|