@vellumai/assistant 0.4.0 → 0.4.2
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/bun.lock +0 -83
- package/package.json +2 -3
- package/src/__tests__/channel-approval-routes.test.ts +55 -5
- package/src/__tests__/daemon-server-session-init.test.ts +54 -1
- package/src/__tests__/guardian-control-plane-policy.test.ts +6 -2
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +4 -2
- package/src/__tests__/guardian-routing-invariants.test.ts +50 -9
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +161 -2
- package/src/__tests__/send-endpoint-busy.test.ts +413 -3
- package/src/approvals/guardian-decision-primitive.ts +22 -1
- package/src/daemon/handlers/sessions.ts +125 -11
- package/src/daemon/response-tier.ts +6 -5
- package/src/daemon/server.ts +17 -2
- package/src/daemon/session-agent-loop.ts +33 -22
- package/src/memory/app-store.ts +6 -0
- package/src/memory/embedding-local.ts +25 -13
- package/src/memory/embedding-runtime-manager.ts +24 -6
- package/src/runtime/guardian-context-resolver.ts +5 -1
- package/src/runtime/guardian-reply-router.ts +12 -0
- package/src/runtime/http-server.ts +1 -0
- package/src/runtime/routes/conversation-routes.ts +187 -2
- package/src/runtime/routes/inbound-message-handler.ts +12 -1
- package/src/tools/apps/executors.ts +15 -0
- package/src/tools/reminder/reminder-store.ts +10 -14
|
@@ -7,8 +7,11 @@ import { type InterfaceId,isChannelId, parseChannelId, parseInterfaceId } from '
|
|
|
7
7
|
import { getConfig } from '../../config/loader.js';
|
|
8
8
|
import { getAttachmentsForMessage, getFilePathForAttachment, setAttachmentThumbnail } from '../../memory/attachments-store.js';
|
|
9
9
|
import {
|
|
10
|
+
createCanonicalGuardianRequest,
|
|
11
|
+
generateCanonicalRequestCode,
|
|
10
12
|
listCanonicalGuardianRequests,
|
|
11
13
|
listPendingCanonicalGuardianRequestsByDestinationConversation,
|
|
14
|
+
resolveCanonicalGuardianRequest,
|
|
12
15
|
} from '../../memory/canonical-guardian-store.js';
|
|
13
16
|
import { getAttentionStateByConversationIds } from '../../memory/conversation-attention-store.js';
|
|
14
17
|
import * as conversationStore from '../../memory/conversation-store.js';
|
|
@@ -47,6 +50,7 @@ import { normalizeThreadType } from '../ipc-protocol.js';
|
|
|
47
50
|
import { executeRecordingIntent } from '../recording-executor.js';
|
|
48
51
|
import { resolveRecordingIntent } from '../recording-intent.js';
|
|
49
52
|
import { classifyRecordingIntentFallback, containsRecordingKeywords } from '../recording-intent-fallback.js';
|
|
53
|
+
import type { Session } from '../session.js';
|
|
50
54
|
import { buildSessionErrorMessage,classifySessionError } from '../session-error.js';
|
|
51
55
|
import { resolveChannelCapabilities } from '../session-runtime-assembly.js';
|
|
52
56
|
import { generateVideoThumbnail } from '../video-thumbnail.js';
|
|
@@ -66,6 +70,86 @@ import {
|
|
|
66
70
|
|
|
67
71
|
const desktopApprovalConversationGenerator = createApprovalConversationGenerator();
|
|
68
72
|
|
|
73
|
+
function syncCanonicalStatusFromIpcConfirmationDecision(
|
|
74
|
+
requestId: string,
|
|
75
|
+
decision: ConfirmationResponse['decision'],
|
|
76
|
+
): void {
|
|
77
|
+
const targetStatus = decision === 'deny' || decision === 'always_deny'
|
|
78
|
+
? 'denied' as const
|
|
79
|
+
: 'approved' as const;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
resolveCanonicalGuardianRequest(requestId, 'pending', { status: targetStatus });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
log.debug(
|
|
85
|
+
{ err, requestId, targetStatus },
|
|
86
|
+
'Failed to resolve canonical request from IPC confirmation response',
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function makeIpcEventSender(params: {
|
|
92
|
+
ctx: HandlerContext;
|
|
93
|
+
socket: net.Socket;
|
|
94
|
+
session: Session;
|
|
95
|
+
conversationId: string;
|
|
96
|
+
sourceChannel: string;
|
|
97
|
+
}): (event: ServerMessage) => void {
|
|
98
|
+
const {
|
|
99
|
+
ctx,
|
|
100
|
+
socket,
|
|
101
|
+
session,
|
|
102
|
+
conversationId,
|
|
103
|
+
sourceChannel,
|
|
104
|
+
} = params;
|
|
105
|
+
|
|
106
|
+
return (event: ServerMessage) => {
|
|
107
|
+
if (event.type === 'confirmation_request') {
|
|
108
|
+
pendingInteractions.register(event.requestId, {
|
|
109
|
+
session,
|
|
110
|
+
conversationId,
|
|
111
|
+
kind: 'confirmation',
|
|
112
|
+
confirmationDetails: {
|
|
113
|
+
toolName: event.toolName,
|
|
114
|
+
input: event.input,
|
|
115
|
+
riskLevel: event.riskLevel,
|
|
116
|
+
executionTarget: event.executionTarget,
|
|
117
|
+
allowlistOptions: event.allowlistOptions,
|
|
118
|
+
scopeOptions: event.scopeOptions,
|
|
119
|
+
persistentDecisionsAllowed: event.persistentDecisionsAllowed,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
createCanonicalGuardianRequest({
|
|
125
|
+
id: event.requestId,
|
|
126
|
+
kind: 'tool_approval',
|
|
127
|
+
sourceType: 'desktop',
|
|
128
|
+
sourceChannel,
|
|
129
|
+
conversationId,
|
|
130
|
+
toolName: event.toolName,
|
|
131
|
+
status: 'pending',
|
|
132
|
+
requestCode: generateCanonicalRequestCode(),
|
|
133
|
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
134
|
+
});
|
|
135
|
+
} catch (err) {
|
|
136
|
+
log.debug(
|
|
137
|
+
{ err, requestId: event.requestId, conversationId },
|
|
138
|
+
'Failed to create canonical request from IPC confirmation event',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
} else if (event.type === 'secret_request') {
|
|
142
|
+
pendingInteractions.register(event.requestId, {
|
|
143
|
+
session,
|
|
144
|
+
conversationId,
|
|
145
|
+
kind: 'secret',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
ctx.send(socket, event);
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
69
153
|
export async function handleUserMessage(
|
|
70
154
|
msg: UserMessage,
|
|
71
155
|
socket: net.Socket,
|
|
@@ -83,8 +167,14 @@ export async function handleUserMessage(
|
|
|
83
167
|
wireEscalationHandler(session, socket, ctx);
|
|
84
168
|
}
|
|
85
169
|
|
|
86
|
-
const sendEvent = (event: ServerMessage) => ctx.send(socket, event);
|
|
87
170
|
const ipcChannel = parseChannelId(msg.channel) ?? 'vellum';
|
|
171
|
+
const sendEvent = makeIpcEventSender({
|
|
172
|
+
ctx,
|
|
173
|
+
socket,
|
|
174
|
+
session,
|
|
175
|
+
conversationId: msg.sessionId,
|
|
176
|
+
sourceChannel: ipcChannel,
|
|
177
|
+
});
|
|
88
178
|
const ipcInterface = parseInterfaceId(msg.interface);
|
|
89
179
|
if (!ipcInterface) {
|
|
90
180
|
ctx.send(socket, {
|
|
@@ -461,11 +551,13 @@ export async function handleUserMessage(
|
|
|
461
551
|
}
|
|
462
552
|
}
|
|
463
553
|
|
|
464
|
-
// If
|
|
465
|
-
//
|
|
554
|
+
// If a live turn is waiting on confirmation, try to consume this text as
|
|
555
|
+
// an inline approval decision before auto-deny. We intentionally do not
|
|
556
|
+
// gate on queue depth: users often retry "approve"/"yes" while the queue
|
|
557
|
+
// is draining after a prior denial, and requiring an empty queue causes a
|
|
558
|
+
// deny/retry cascade where natural-language approvals never land.
|
|
466
559
|
if (
|
|
467
560
|
session.hasAnyPendingConfirmation()
|
|
468
|
-
&& session.getQueueDepth() === 0
|
|
469
561
|
&& messageText.trim().length > 0
|
|
470
562
|
) {
|
|
471
563
|
try {
|
|
@@ -598,6 +690,7 @@ export async function handleUserMessage(
|
|
|
598
690
|
// stale request IDs are not reused as routing candidates.
|
|
599
691
|
for (const interaction of pendingInteractions.getByConversation(msg.sessionId)) {
|
|
600
692
|
if (interaction.session === session && interaction.kind === 'confirmation') {
|
|
693
|
+
syncCanonicalStatusFromIpcConfirmationDecision(interaction.requestId, 'deny');
|
|
601
694
|
pendingInteractions.resolve(interaction.requestId);
|
|
602
695
|
}
|
|
603
696
|
}
|
|
@@ -638,6 +731,8 @@ export function handleConfirmationResponse(
|
|
|
638
731
|
msg.selectedPattern,
|
|
639
732
|
msg.selectedScope,
|
|
640
733
|
);
|
|
734
|
+
syncCanonicalStatusFromIpcConfirmationDecision(msg.requestId, msg.decision);
|
|
735
|
+
pendingInteractions.resolve(msg.requestId);
|
|
641
736
|
return;
|
|
642
737
|
}
|
|
643
738
|
}
|
|
@@ -651,6 +746,8 @@ export function handleConfirmationResponse(
|
|
|
651
746
|
msg.selectedPattern,
|
|
652
747
|
msg.selectedScope,
|
|
653
748
|
);
|
|
749
|
+
syncCanonicalStatusFromIpcConfirmationDecision(msg.requestId, msg.decision);
|
|
750
|
+
pendingInteractions.resolve(msg.requestId);
|
|
654
751
|
return;
|
|
655
752
|
}
|
|
656
753
|
}
|
|
@@ -670,6 +767,7 @@ export function handleSecretResponse(
|
|
|
670
767
|
clearTimeout(standalone.timer);
|
|
671
768
|
pendingStandaloneSecrets.delete(msg.requestId);
|
|
672
769
|
standalone.resolve({ value: msg.value ?? null, delivery: msg.delivery ?? 'store' });
|
|
770
|
+
pendingInteractions.resolve(msg.requestId);
|
|
673
771
|
return;
|
|
674
772
|
}
|
|
675
773
|
|
|
@@ -680,6 +778,7 @@ export function handleSecretResponse(
|
|
|
680
778
|
if (session.hasPendingSecret(msg.requestId)) {
|
|
681
779
|
ctx.touchSession(sessionId);
|
|
682
780
|
session.handleSecretResponse(msg.requestId, msg.value, msg.delivery);
|
|
781
|
+
pendingInteractions.resolve(msg.requestId);
|
|
683
782
|
return;
|
|
684
783
|
}
|
|
685
784
|
}
|
|
@@ -780,11 +879,11 @@ export async function handleSessionCreate(
|
|
|
780
879
|
|
|
781
880
|
// Auto-send the initial message if provided, kick-starting the skill.
|
|
782
881
|
if (msg.initialMessage) {
|
|
783
|
-
// Queue title generation
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
882
|
+
// Queue title generation eagerly — some processMessage paths (guardian
|
|
883
|
+
// replies, unknown slash commands) bypass the agent loop entirely, so
|
|
884
|
+
// we can't rely on the agent loop's early title generation alone.
|
|
885
|
+
// The agent loop also queues title generation, but isReplaceableTitle
|
|
886
|
+
// prevents double-writes since the first to complete sets a real title.
|
|
788
887
|
if (title === GENERATING_TITLE) {
|
|
789
888
|
queueGenerateConversationTitle({
|
|
790
889
|
conversationId: conversation.id,
|
|
@@ -801,9 +900,15 @@ export async function handleSessionCreate(
|
|
|
801
900
|
}
|
|
802
901
|
|
|
803
902
|
ctx.socketToSession.set(socket, conversation.id);
|
|
804
|
-
const sendEvent = (event: ServerMessage) => ctx.send(socket, event);
|
|
805
903
|
const requestId = uuid();
|
|
806
904
|
const transportChannel = parseChannelId(msg.transport?.channelId) ?? 'vellum';
|
|
905
|
+
const sendEvent = makeIpcEventSender({
|
|
906
|
+
ctx,
|
|
907
|
+
socket,
|
|
908
|
+
session,
|
|
909
|
+
conversationId: conversation.id,
|
|
910
|
+
sourceChannel: transportChannel,
|
|
911
|
+
});
|
|
807
912
|
session.setTurnChannelContext({
|
|
808
913
|
userMessageChannel: transportChannel,
|
|
809
914
|
assistantMessageChannel: transportChannel,
|
|
@@ -1136,7 +1241,16 @@ export async function handleRegenerate(
|
|
|
1136
1241
|
}
|
|
1137
1242
|
ctx.touchSession(msg.sessionId);
|
|
1138
1243
|
|
|
1139
|
-
const
|
|
1244
|
+
const regenerateChannel = parseChannelId(
|
|
1245
|
+
session.getTurnChannelContext()?.assistantMessageChannel,
|
|
1246
|
+
) ?? 'vellum';
|
|
1247
|
+
const sendEvent = makeIpcEventSender({
|
|
1248
|
+
ctx,
|
|
1249
|
+
socket,
|
|
1250
|
+
session,
|
|
1251
|
+
conversationId: msg.sessionId,
|
|
1252
|
+
sourceChannel: regenerateChannel,
|
|
1253
|
+
});
|
|
1140
1254
|
const requestId = uuid();
|
|
1141
1255
|
session.traceEmitter.emit('request_received', 'Regenerate requested', {
|
|
1142
1256
|
requestId,
|
|
@@ -145,15 +145,16 @@ const TIER_SYSTEM_PROMPT =
|
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
147
|
* Fire-and-forget Haiku call to classify the conversation trajectory.
|
|
148
|
-
* Returns the classified tier or
|
|
148
|
+
* Returns the classified tier, or undefined when no provider is configured
|
|
149
|
+
* or on any failure.
|
|
149
150
|
*/
|
|
150
151
|
export async function classifyResponseTierAsync(
|
|
151
152
|
recentUserTexts: string[],
|
|
152
|
-
): Promise<ResponseTier |
|
|
153
|
+
): Promise<ResponseTier | undefined> {
|
|
153
154
|
const provider = getConfiguredProvider();
|
|
154
155
|
if (!provider) {
|
|
155
156
|
log.debug('No provider available for async tier classification');
|
|
156
|
-
return
|
|
157
|
+
return undefined;
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
const combined = recentUserTexts
|
|
@@ -186,14 +187,14 @@ export async function classifyResponseTierAsync(
|
|
|
186
187
|
}
|
|
187
188
|
|
|
188
189
|
log.debug({ raw }, 'Async tier classification returned unexpected value');
|
|
189
|
-
return
|
|
190
|
+
return undefined;
|
|
190
191
|
} finally {
|
|
191
192
|
cleanup();
|
|
192
193
|
}
|
|
193
194
|
} catch (err) {
|
|
194
195
|
const message = err instanceof Error ? err.message : String(err);
|
|
195
196
|
log.debug({ err: message }, 'Async tier classification failed');
|
|
196
|
-
return
|
|
197
|
+
return undefined;
|
|
197
198
|
}
|
|
198
199
|
}
|
|
199
200
|
|
package/src/daemon/server.ts
CHANGED
|
@@ -93,6 +93,16 @@ function resolveTurnInterface(sourceInterface?: string): InterfaceId {
|
|
|
93
93
|
return 'vellum';
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
function resolveCanonicalRequestSourceType(sourceChannel: string | undefined): 'desktop' | 'channel' | 'voice' {
|
|
97
|
+
if (sourceChannel === 'voice') {
|
|
98
|
+
return 'voice';
|
|
99
|
+
}
|
|
100
|
+
if (sourceChannel === 'vellum') {
|
|
101
|
+
return 'desktop';
|
|
102
|
+
}
|
|
103
|
+
return 'channel';
|
|
104
|
+
}
|
|
105
|
+
|
|
96
106
|
/**
|
|
97
107
|
* Build an onEvent callback that registers pending interactions when the agent
|
|
98
108
|
* loop emits confirmation_request or secret_request events. This ensures that
|
|
@@ -121,12 +131,17 @@ function makePendingInteractionRegistrar(
|
|
|
121
131
|
|
|
122
132
|
// Create a canonical guardian request so IPC/HTTP handlers can find it
|
|
123
133
|
// via applyCanonicalGuardianDecision.
|
|
134
|
+
const guardianContext = session.guardianContext;
|
|
135
|
+
const sourceChannel = guardianContext?.sourceChannel ?? 'vellum';
|
|
124
136
|
createCanonicalGuardianRequest({
|
|
125
137
|
id: msg.requestId,
|
|
126
138
|
kind: 'tool_approval',
|
|
127
|
-
sourceType:
|
|
128
|
-
sourceChannel
|
|
139
|
+
sourceType: resolveCanonicalRequestSourceType(sourceChannel),
|
|
140
|
+
sourceChannel,
|
|
129
141
|
conversationId,
|
|
142
|
+
requesterExternalUserId: guardianContext?.requesterExternalUserId,
|
|
143
|
+
requesterChatId: guardianContext?.requesterChatId,
|
|
144
|
+
guardianExternalUserId: guardianContext?.guardianExternalUserId,
|
|
130
145
|
toolName: msg.toolName,
|
|
131
146
|
status: 'pending',
|
|
132
147
|
requestCode: generateCanonicalRequestCode(),
|
|
@@ -20,7 +20,7 @@ import { commitAppTurnChanges } from '../memory/app-git-service.js';
|
|
|
20
20
|
import { getApp, listAppFiles } from '../memory/app-store.js';
|
|
21
21
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
22
22
|
import { getConversationOriginChannel, getConversationOriginInterface, provenanceFromGuardianContext } from '../memory/conversation-store.js';
|
|
23
|
-
import { isReplaceableTitle, queueGenerateConversationTitle, queueRegenerateConversationTitle } from '../memory/conversation-title-service.js';
|
|
23
|
+
import { GENERATING_TITLE, isReplaceableTitle, queueGenerateConversationTitle, queueRegenerateConversationTitle, UNTITLED_FALLBACK } from '../memory/conversation-title-service.js';
|
|
24
24
|
import { stripMemoryRecallMessages } from '../memory/retriever.js';
|
|
25
25
|
import type { PermissionPrompter } from '../permissions/prompter.js';
|
|
26
26
|
import type { ContentBlock,Message } from '../providers/types.js';
|
|
@@ -211,10 +211,42 @@ export async function runAgentLoopImpl(
|
|
|
211
211
|
ctx.messages.pop();
|
|
212
212
|
conversationStore.deleteMessageById(userMessageId);
|
|
213
213
|
}
|
|
214
|
+
// Replace loading placeholder so the thread isn't stuck as "Generating title..."
|
|
215
|
+
const blockedConv = conversationStore.getConversation(ctx.conversationId);
|
|
216
|
+
if (blockedConv?.title === GENERATING_TITLE) {
|
|
217
|
+
conversationStore.updateConversationTitle(ctx.conversationId, UNTITLED_FALLBACK, 1);
|
|
218
|
+
onEvent({ type: 'session_title_updated', sessionId: ctx.conversationId, title: UNTITLED_FALLBACK });
|
|
219
|
+
}
|
|
214
220
|
onEvent({ type: 'error', message: `Message blocked by hook "${preMessageResult.blockedBy}"` });
|
|
215
221
|
return;
|
|
216
222
|
}
|
|
217
223
|
|
|
224
|
+
// Generate title early — the user message alone is sufficient context.
|
|
225
|
+
// Firing after hook gating but before the main LLM call removes the
|
|
226
|
+
// delay of waiting for the full assistant response. The second-pass
|
|
227
|
+
// regeneration at turn 3 will refine the title with more context.
|
|
228
|
+
// Deferred via setTimeout so the main agent loop LLM call is queued
|
|
229
|
+
// first, avoiding rate-limit slot contention. No abort signal — title
|
|
230
|
+
// generation should complete even if the user cancels the response,
|
|
231
|
+
// since the user message is already persisted.
|
|
232
|
+
const currentConvForTitle = conversationStore.getConversation(ctx.conversationId);
|
|
233
|
+
if (isReplaceableTitle(currentConvForTitle?.title ?? null)) {
|
|
234
|
+
setTimeout(() => {
|
|
235
|
+
queueGenerateConversationTitle({
|
|
236
|
+
conversationId: ctx.conversationId,
|
|
237
|
+
provider: ctx.provider,
|
|
238
|
+
userMessage: options?.titleText ?? content,
|
|
239
|
+
onTitleUpdated: (title) => {
|
|
240
|
+
onEvent({
|
|
241
|
+
type: 'session_title_updated',
|
|
242
|
+
sessionId: ctx.conversationId,
|
|
243
|
+
title,
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
}, 0);
|
|
248
|
+
}
|
|
249
|
+
|
|
218
250
|
const isFirstMessage = ctx.messages.length === 1;
|
|
219
251
|
|
|
220
252
|
const compacted = await ctx.contextWindowManager.maybeCompact(
|
|
@@ -721,27 +753,6 @@ export async function runAgentLoopImpl(
|
|
|
721
753
|
});
|
|
722
754
|
}
|
|
723
755
|
|
|
724
|
-
// Generate title if the current conversation title is still a replaceable
|
|
725
|
-
// placeholder. This replaces the previous `isFirstMessage` gate so that
|
|
726
|
-
// assistant-seeded/system-seeded threads also receive generated titles.
|
|
727
|
-
const currentConv = conversationStore.getConversation(ctx.conversationId);
|
|
728
|
-
if (isReplaceableTitle(currentConv?.title ?? null)) {
|
|
729
|
-
queueGenerateConversationTitle({
|
|
730
|
-
conversationId: ctx.conversationId,
|
|
731
|
-
provider: ctx.provider,
|
|
732
|
-
userMessage: options?.titleText ?? content,
|
|
733
|
-
assistantResponse: state.firstAssistantText || undefined,
|
|
734
|
-
onTitleUpdated: (title) => {
|
|
735
|
-
onEvent({
|
|
736
|
-
type: 'session_title_updated',
|
|
737
|
-
sessionId: ctx.conversationId,
|
|
738
|
-
title,
|
|
739
|
-
});
|
|
740
|
-
},
|
|
741
|
-
signal: abortController.signal,
|
|
742
|
-
});
|
|
743
|
-
}
|
|
744
|
-
|
|
745
756
|
// Second title pass: after 3 completed turns, re-generate the title
|
|
746
757
|
// using the last 3 messages for better context. Only fires when the
|
|
747
758
|
// current title was auto-generated (isAutoTitle = 1).
|
package/src/memory/app-store.ts
CHANGED
|
@@ -147,6 +147,9 @@ function savePages(appId: string, pages: Record<string, string>): void {
|
|
|
147
147
|
mkdirSync(pagesDir, { recursive: true });
|
|
148
148
|
for (const [filename, content] of Object.entries(pages)) {
|
|
149
149
|
validatePageFilename(filename);
|
|
150
|
+
if (typeof content !== 'string') {
|
|
151
|
+
throw new Error(`Page content for "${filename}" must be a string, got ${typeof content}`);
|
|
152
|
+
}
|
|
150
153
|
writeFileSync(join(pagesDir, filename), content, 'utf-8');
|
|
151
154
|
}
|
|
152
155
|
}
|
|
@@ -194,6 +197,9 @@ export function createApp(params: {
|
|
|
194
197
|
// Write htmlDefinition to {appId}/index.html on disk
|
|
195
198
|
const appDir = join(dir, app.id);
|
|
196
199
|
mkdirSync(appDir, { recursive: true });
|
|
200
|
+
if (typeof params.htmlDefinition !== 'string') {
|
|
201
|
+
throw new Error(`htmlDefinition must be a string, got ${typeof params.htmlDefinition}`);
|
|
202
|
+
}
|
|
197
203
|
writeFileSync(join(appDir, 'index.html'), params.htmlDefinition, 'utf-8');
|
|
198
204
|
|
|
199
205
|
// Write preview to companion file to keep the JSON small
|
|
@@ -81,7 +81,11 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
|
|
|
81
81
|
}
|
|
82
82
|
this.pendingRequests.set(id, { resolve });
|
|
83
83
|
this.workerProc.stdin.write(JSON.stringify({ id, texts }) + '\n');
|
|
84
|
-
|
|
84
|
+
try {
|
|
85
|
+
this.workerProc.stdin.flush();
|
|
86
|
+
} catch {
|
|
87
|
+
// Worker may have exited — pending request will be resolved by stdout reader cleanup
|
|
88
|
+
}
|
|
85
89
|
});
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -138,8 +142,10 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
|
|
|
138
142
|
// Wait for the worker to signal it's ready (model loaded)
|
|
139
143
|
await this.waitForReady();
|
|
140
144
|
} catch (err) {
|
|
141
|
-
// Worker failed to start — collect stderr
|
|
145
|
+
// Worker failed to start — kill it to avoid deadlock, then collect stderr
|
|
142
146
|
this.workerProc = null;
|
|
147
|
+
this.stdoutReaderActive = false;
|
|
148
|
+
try { proc.kill(); } catch { /* may already be dead */ }
|
|
143
149
|
const exitCode = await proc.exited.catch(() => undefined);
|
|
144
150
|
const stderr = await new Response(proc.stderr).text().catch(() => '');
|
|
145
151
|
if (stderr.trim()) {
|
|
@@ -180,7 +186,9 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
|
|
|
180
186
|
if (this.stdoutReaderActive || !this.workerProc) return;
|
|
181
187
|
this.stdoutReaderActive = true;
|
|
182
188
|
|
|
183
|
-
|
|
189
|
+
// Capture reference to detect if a new worker was spawned during cleanup
|
|
190
|
+
const proc = this.workerProc;
|
|
191
|
+
const reader = proc.stdout.getReader();
|
|
184
192
|
const decoder = new TextDecoder();
|
|
185
193
|
|
|
186
194
|
(async () => {
|
|
@@ -195,17 +203,21 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
|
|
|
195
203
|
// Reader cancelled or stream errored
|
|
196
204
|
}
|
|
197
205
|
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
// Only clean up if this reader's proc is still the active one.
|
|
207
|
+
// A new worker may have been spawned during the async cleanup window.
|
|
208
|
+
if (this.workerProc === proc) {
|
|
209
|
+
// Worker exited — reject all pending requests and clean up
|
|
210
|
+
for (const [, pending] of this.pendingRequests) {
|
|
211
|
+
pending.resolve({ error: 'Embedding worker process exited unexpectedly' });
|
|
212
|
+
}
|
|
213
|
+
this.pendingRequests.clear();
|
|
214
|
+
this.workerProc = null;
|
|
215
|
+
this.stdoutReaderActive = false;
|
|
216
|
+
this.removePidFile();
|
|
217
|
+
this.stdoutBuffer = '';
|
|
218
|
+
// Allow re-initialization on next embed() call
|
|
219
|
+
this.initGuard.reset();
|
|
201
220
|
}
|
|
202
|
-
this.pendingRequests.clear();
|
|
203
|
-
this.workerProc = null;
|
|
204
|
-
this.stdoutReaderActive = false;
|
|
205
|
-
this.removePidFile();
|
|
206
|
-
this.stdoutBuffer = '';
|
|
207
|
-
// Allow re-initialization on next embed() call
|
|
208
|
-
this.initGuard.reset();
|
|
209
221
|
})();
|
|
210
222
|
}
|
|
211
223
|
|
|
@@ -27,12 +27,13 @@ const log = getLogger('embedding-runtime-manager');
|
|
|
27
27
|
const ONNXRUNTIME_NODE_VERSION = '1.21.0';
|
|
28
28
|
const ONNXRUNTIME_COMMON_VERSION = '1.21.0';
|
|
29
29
|
const TRANSFORMERS_VERSION = '3.8.1';
|
|
30
|
+
const JINJA_VERSION = '0.5.5';
|
|
30
31
|
|
|
31
32
|
/** Bun version to download when system bun is not available. */
|
|
32
33
|
const BUN_VERSION = '1.2.0';
|
|
33
34
|
|
|
34
35
|
/** Composite version string for cache invalidation. */
|
|
35
|
-
const RUNTIME_VERSION = `ort-${ONNXRUNTIME_NODE_VERSION}_hf-${TRANSFORMERS_VERSION}`;
|
|
36
|
+
const RUNTIME_VERSION = `ort-${ONNXRUNTIME_NODE_VERSION}_hf-${TRANSFORMERS_VERSION}_jinja-${JINJA_VERSION}`;
|
|
36
37
|
|
|
37
38
|
const WORKER_FILENAME = 'embed-worker.mjs';
|
|
38
39
|
|
|
@@ -104,6 +105,7 @@ function generateWorkerScript(): string {
|
|
|
104
105
|
return `\
|
|
105
106
|
// embed-worker.mjs — Auto-generated by EmbeddingRuntimeManager
|
|
106
107
|
// Runs in a separate bun process, communicates via JSON-lines over stdin/stdout.
|
|
108
|
+
process.title = 'embed-worker';
|
|
107
109
|
import { pipeline, env } from '@huggingface/transformers';
|
|
108
110
|
|
|
109
111
|
const model = process.argv[2];
|
|
@@ -272,6 +274,12 @@ export class EmbeddingRuntimeManager {
|
|
|
272
274
|
const tmpDir = join(this.baseDir, `.installing-${Date.now()}`);
|
|
273
275
|
mkdirSync(tmpDir, { recursive: true });
|
|
274
276
|
|
|
277
|
+
// Declared outside try so catch/finally can reference them for cleanup
|
|
278
|
+
const modelCacheDir = join(this.baseDir, 'model-cache');
|
|
279
|
+
const existingBinDir = join(this.baseDir, 'bin');
|
|
280
|
+
let tmpModelCache: string | null = null;
|
|
281
|
+
let tmpBinDir: string | null = null;
|
|
282
|
+
|
|
275
283
|
try {
|
|
276
284
|
// Step 1: Download npm packages (and bun if needed) in parallel
|
|
277
285
|
const nodeModules = join(tmpDir, 'node_modules');
|
|
@@ -291,6 +299,11 @@ export class EmbeddingRuntimeManager {
|
|
|
291
299
|
join(nodeModules, '@huggingface', 'transformers'),
|
|
292
300
|
signal,
|
|
293
301
|
),
|
|
302
|
+
downloadAndExtract(
|
|
303
|
+
npmTarballUrl('@huggingface/jinja', JINJA_VERSION),
|
|
304
|
+
join(nodeModules, '@huggingface', 'jinja'),
|
|
305
|
+
signal,
|
|
306
|
+
),
|
|
294
307
|
];
|
|
295
308
|
|
|
296
309
|
// Download bun binary if not already available on the system
|
|
@@ -358,19 +371,15 @@ export class EmbeddingRuntimeManager {
|
|
|
358
371
|
|
|
359
372
|
// Step 6: Atomic swap — remove old install and rename temp to final
|
|
360
373
|
// Preserve model-cache/, bin/ (downloaded bun), and .gitignore
|
|
361
|
-
const modelCacheDir = join(this.baseDir, 'model-cache');
|
|
362
374
|
const hadModelCache = existsSync(modelCacheDir);
|
|
363
|
-
let tmpModelCache: string | null = null;
|
|
364
375
|
if (hadModelCache) {
|
|
365
376
|
tmpModelCache = join(this.baseDir, `.model-cache-preserve-${Date.now()}`);
|
|
366
377
|
renameSync(modelCacheDir, tmpModelCache);
|
|
367
378
|
}
|
|
368
379
|
|
|
369
380
|
// Preserve downloaded bun binary if it exists and we didn't just download a new one
|
|
370
|
-
const existingBinDir = join(this.baseDir, 'bin');
|
|
371
381
|
const newBinDir = join(tmpDir, 'bin');
|
|
372
382
|
const hadBinDir = existsSync(existingBinDir) && !existsSync(newBinDir);
|
|
373
|
-
let tmpBinDir: string | null = null;
|
|
374
383
|
if (hadBinDir) {
|
|
375
384
|
tmpBinDir = join(this.baseDir, `.bin-preserve-${Date.now()}`);
|
|
376
385
|
renameSync(existingBinDir, tmpBinDir);
|
|
@@ -399,11 +408,20 @@ export class EmbeddingRuntimeManager {
|
|
|
399
408
|
|
|
400
409
|
log.info({ runtimeVersion: RUNTIME_VERSION }, 'Embedding runtime installed successfully');
|
|
401
410
|
} catch (err) {
|
|
411
|
+
// Restore preserved directories if the swap failed
|
|
412
|
+
if (tmpModelCache && existsSync(tmpModelCache) && !existsSync(modelCacheDir)) {
|
|
413
|
+
try { renameSync(tmpModelCache, modelCacheDir); } catch { /* best effort */ }
|
|
414
|
+
}
|
|
415
|
+
if (tmpBinDir && existsSync(tmpBinDir) && !existsSync(existingBinDir)) {
|
|
416
|
+
try { renameSync(tmpBinDir, existingBinDir); } catch { /* best effort */ }
|
|
417
|
+
}
|
|
402
418
|
log.error({ err }, 'Failed to install embedding runtime');
|
|
403
419
|
throw err;
|
|
404
420
|
} finally {
|
|
405
|
-
// Clean up temp directory
|
|
421
|
+
// Clean up temp directory and any leftover preserve dirs
|
|
406
422
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
423
|
+
if (tmpModelCache) rmSync(tmpModelCache, { recursive: true, force: true });
|
|
424
|
+
if (tmpBinDir) rmSync(tmpBinDir, { recursive: true, force: true });
|
|
407
425
|
}
|
|
408
426
|
}
|
|
409
427
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { ChannelId } from '../channels/types.js';
|
|
9
9
|
import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
|
|
10
|
+
import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
|
|
10
11
|
import {
|
|
11
12
|
type DenialReason,
|
|
12
13
|
resolveActorTrust,
|
|
@@ -41,11 +42,14 @@ export type ResolveGuardianContextInput = ResolveActorTrustInput;
|
|
|
41
42
|
*/
|
|
42
43
|
export function resolveGuardianContext(input: ResolveGuardianContextInput): GuardianContext {
|
|
43
44
|
const trust = resolveActorTrust(input);
|
|
45
|
+
const canonicalGuardianExternalUserId = trust.guardianBindingMatch?.guardianExternalUserId
|
|
46
|
+
? canonicalizeInboundIdentity(input.sourceChannel, trust.guardianBindingMatch.guardianExternalUserId) ?? undefined
|
|
47
|
+
: undefined;
|
|
44
48
|
return {
|
|
45
49
|
trustClass: trust.trustClass,
|
|
46
50
|
guardianChatId: trust.guardianBindingMatch?.guardianDeliveryChatId ??
|
|
47
51
|
(trust.trustClass === 'guardian' ? input.externalChatId : undefined),
|
|
48
|
-
guardianExternalUserId:
|
|
52
|
+
guardianExternalUserId: canonicalGuardianExternalUserId,
|
|
49
53
|
requesterIdentifier: trust.actorMetadata.identifier,
|
|
50
54
|
requesterDisplayName: trust.actorMetadata.displayName,
|
|
51
55
|
requesterSenderDisplayName: trust.actorMetadata.senderDisplayName,
|
|
@@ -237,6 +237,7 @@ export async function routeGuardianReply(
|
|
|
237
237
|
): Promise<GuardianReplyResult> {
|
|
238
238
|
const { messageText, actor, conversationId, callbackData, approvalConversationGenerator, channelDeliveryContext } = ctx;
|
|
239
239
|
const pendingRequests = findPendingCanonicalRequests(actor, ctx.pendingRequestIds, conversationId);
|
|
240
|
+
const scopedPendingRequestIds = ctx.pendingRequestIds ? new Set(ctx.pendingRequestIds) : null;
|
|
240
241
|
|
|
241
242
|
// ── 1. Deterministic callback parsing (button presses) ──
|
|
242
243
|
// No conversationId scoping here — the guardian's reply comes from a
|
|
@@ -257,6 +258,17 @@ export async function routeGuardianReply(
|
|
|
257
258
|
const codeResult = parseRequestCode(messageText);
|
|
258
259
|
if (codeResult) {
|
|
259
260
|
const { request } = codeResult;
|
|
261
|
+
if (scopedPendingRequestIds && !scopedPendingRequestIds.has(request.id)) {
|
|
262
|
+
log.info(
|
|
263
|
+
{
|
|
264
|
+
event: 'router_code_out_of_scope',
|
|
265
|
+
requestId: request.id,
|
|
266
|
+
pendingHintCount: scopedPendingRequestIds.size,
|
|
267
|
+
},
|
|
268
|
+
'Request code matched a pending request outside the caller-provided scope; ignoring',
|
|
269
|
+
);
|
|
270
|
+
return notConsumed();
|
|
271
|
+
}
|
|
260
272
|
|
|
261
273
|
if (request.status !== 'pending') {
|
|
262
274
|
log.info(
|
|
@@ -714,6 +714,7 @@ export class RuntimeHttpServer {
|
|
|
714
714
|
processMessage: this.processMessage,
|
|
715
715
|
persistAndProcessMessage: this.persistAndProcessMessage,
|
|
716
716
|
sendMessageDeps: this.sendMessageDeps,
|
|
717
|
+
approvalConversationGenerator: this.approvalConversationGenerator,
|
|
717
718
|
});
|
|
718
719
|
}
|
|
719
720
|
|