@vellumai/assistant 0.3.2 → 0.3.3
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 +82 -13
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
- package/src/__tests__/app-git-history.test.ts +22 -27
- package/src/__tests__/app-git-service.test.ts +44 -78
- package/src/__tests__/channel-approval-routes.test.ts +930 -14
- package/src/__tests__/channel-approval.test.ts +2 -0
- package/src/__tests__/channel-delivery-store.test.ts +104 -1
- package/src/__tests__/channel-guardian.test.ts +184 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +5 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +87 -8
- package/src/__tests__/handlers-telegram-config.test.ts +82 -0
- package/src/__tests__/handlers-twilio-config.test.ts +665 -5
- package/src/__tests__/ingress-url-consistency.test.ts +64 -0
- package/src/__tests__/ipc-snapshot.test.ts +10 -0
- package/src/__tests__/run-orchestrator.test.ts +1 -1
- package/src/__tests__/session-process-bridge.test.ts +2 -0
- package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
- package/src/calls/twilio-config.ts +10 -1
- package/src/calls/twilio-rest.ts +70 -0
- package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
- package/src/config/bundled-skills/subagent/SKILL.md +4 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
- package/src/config/schema.ts +3 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +11 -4
- package/src/daemon/handlers/config.ts +168 -15
- package/src/daemon/handlers/sessions.ts +5 -3
- package/src/daemon/handlers/skills.ts +61 -17
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +10 -0
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +20 -3
- package/src/daemon/session-slash.ts +50 -2
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/inbound/public-ingress-urls.ts +20 -3
- package/src/index.ts +1 -23
- package/src/memory/app-git-service.ts +24 -0
- package/src/memory/app-store.ts +0 -21
- package/src/memory/channel-delivery-store.ts +74 -3
- package/src/memory/channel-guardian-store.ts +54 -26
- package/src/memory/conversation-key-store.ts +20 -0
- package/src/memory/conversation-store.ts +14 -2
- package/src/memory/db.ts +12 -0
- package/src/memory/schema.ts +5 -0
- package/src/runtime/http-server.ts +13 -5
- package/src/runtime/routes/channel-routes.ts +134 -43
- package/src/skills/clawhub.ts +6 -2
- package/src/subagent/manager.ts +4 -1
- package/src/subagent/types.ts +2 -0
- package/src/tools/skills/vellum-catalog.ts +45 -2
- package/src/tools/subagent/spawn.ts +2 -0
|
@@ -5,8 +5,9 @@ import { getConfig, loadRawConfig, saveRawConfig, invalidateConfigCache } from '
|
|
|
5
5
|
import { loadSkillCatalog, loadSkillBySelector, ensureSkillIcon } from '../../config/skills.js';
|
|
6
6
|
import { resolveSkillStates } from '../../config/skill-state.js';
|
|
7
7
|
import { getWorkspaceSkillsDir } from '../../util/platform.js';
|
|
8
|
-
import { clawhubInstall, clawhubUpdate, clawhubSearch, clawhubCheckUpdates, clawhubInspect } from '../../skills/clawhub.js';
|
|
8
|
+
import { clawhubInstall, clawhubUpdate, clawhubSearch, clawhubCheckUpdates, clawhubInspect, type ClawhubSearchResultItem } from '../../skills/clawhub.js';
|
|
9
9
|
import { removeSkillsIndexEntry, deleteManagedSkill, validateManagedSkillId } from '../../skills/managed-store.js';
|
|
10
|
+
import { listCatalogEntries, installFromVellumCatalog } from '../../tools/skills/vellum-catalog.js';
|
|
10
11
|
import type {
|
|
11
12
|
SkillDetailRequest,
|
|
12
13
|
SkillsEnableRequest,
|
|
@@ -186,26 +187,45 @@ export async function handleSkillsInstall(
|
|
|
186
187
|
ctx: HandlerContext,
|
|
187
188
|
): Promise<void> {
|
|
188
189
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
190
|
+
// Check if the slug matches a vellum-skills catalog entry first
|
|
191
|
+
const catalogEntries = listCatalogEntries();
|
|
192
|
+
const isVellumSkill = catalogEntries.some((e) => e.id === msg.slug);
|
|
193
|
+
|
|
194
|
+
let skillId: string;
|
|
195
|
+
|
|
196
|
+
if (isVellumSkill) {
|
|
197
|
+
// Install from vellum-skills catalog (local copy)
|
|
198
|
+
const result = installFromVellumCatalog(msg.slug);
|
|
199
|
+
if (!result.success) {
|
|
200
|
+
ctx.send(socket, {
|
|
201
|
+
type: 'skills_operation_response',
|
|
202
|
+
operation: 'install',
|
|
203
|
+
success: false,
|
|
204
|
+
error: result.error ?? 'Unknown error',
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
skillId = result.skillName ?? msg.slug;
|
|
209
|
+
} else {
|
|
210
|
+
// Install from clawhub (community)
|
|
211
|
+
const result = await clawhubInstall(msg.slug, { version: msg.version });
|
|
212
|
+
if (!result.success) {
|
|
213
|
+
ctx.send(socket, {
|
|
214
|
+
type: 'skills_operation_response',
|
|
215
|
+
operation: 'install',
|
|
216
|
+
success: false,
|
|
217
|
+
error: result.error ?? 'Unknown error',
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const rawId = result.skillName ?? msg.slug;
|
|
222
|
+
skillId = rawId.includes('/') ? rawId.split('/').pop()! : rawId;
|
|
198
223
|
}
|
|
199
224
|
|
|
200
225
|
// Reload skill catalog so the newly installed skill is picked up
|
|
201
226
|
loadSkillCatalog();
|
|
202
227
|
|
|
203
228
|
// Auto-enable the newly installed skill so it's immediately usable.
|
|
204
|
-
// Use basename of slug to match the catalog ID (directory basename), since
|
|
205
|
-
// install slugs can be namespaced (e.g. "org/name") but skill state keys use
|
|
206
|
-
// the bare directory name.
|
|
207
|
-
const rawId = result.skillName ?? msg.slug;
|
|
208
|
-
const skillId = rawId.includes('/') ? rawId.split('/').pop()! : rawId;
|
|
209
229
|
try {
|
|
210
230
|
const raw = loadRawConfig();
|
|
211
231
|
ensureSkillEntry(raw, skillId).enabled = true;
|
|
@@ -404,12 +424,36 @@ export async function handleSkillsSearch(
|
|
|
404
424
|
ctx: HandlerContext,
|
|
405
425
|
): Promise<void> {
|
|
406
426
|
try {
|
|
407
|
-
|
|
427
|
+
// Search vellum-skills catalog locally
|
|
428
|
+
const catalogEntries = listCatalogEntries();
|
|
429
|
+
const query = (msg.query ?? '').toLowerCase();
|
|
430
|
+
const matchingCatalog = catalogEntries.filter((e) => {
|
|
431
|
+
if (!query) return true;
|
|
432
|
+
return e.name.toLowerCase().includes(query) || e.description.toLowerCase().includes(query) || e.id.toLowerCase().includes(query);
|
|
433
|
+
});
|
|
434
|
+
const vellumSkills: ClawhubSearchResultItem[] = matchingCatalog.map((e) => ({
|
|
435
|
+
name: e.name,
|
|
436
|
+
slug: e.id,
|
|
437
|
+
description: e.description,
|
|
438
|
+
author: 'Vellum',
|
|
439
|
+
stars: 0,
|
|
440
|
+
installs: 0,
|
|
441
|
+
version: '',
|
|
442
|
+
createdAt: 0,
|
|
443
|
+
source: 'vellum' as const,
|
|
444
|
+
}));
|
|
445
|
+
|
|
446
|
+
// Search clawhub concurrently
|
|
447
|
+
const clawhubResult = await clawhubSearch(msg.query);
|
|
448
|
+
|
|
449
|
+
// Merge: vellum first, then clawhub
|
|
450
|
+
const merged = { skills: [...vellumSkills, ...clawhubResult.skills] };
|
|
451
|
+
|
|
408
452
|
ctx.send(socket, {
|
|
409
453
|
type: 'skills_operation_response',
|
|
410
454
|
operation: 'search',
|
|
411
455
|
success: true,
|
|
412
|
-
data:
|
|
456
|
+
data: merged,
|
|
413
457
|
});
|
|
414
458
|
} catch (err) {
|
|
415
459
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"ToolNamesListRequest",
|
|
92
92
|
"ToolPermissionSimulateRequest",
|
|
93
93
|
"TrustRulesList",
|
|
94
|
+
"TwilioConfigRequest",
|
|
94
95
|
"TwitterAuthStartRequest",
|
|
95
96
|
"TwitterAuthStatusRequest",
|
|
96
97
|
"TwitterIntegrationConfigRequest",
|
|
@@ -215,6 +216,7 @@
|
|
|
215
216
|
"ToolUseStart",
|
|
216
217
|
"TraceEvent",
|
|
217
218
|
"TrustRulesListResponse",
|
|
219
|
+
"TwilioConfigResponse",
|
|
218
220
|
"TwitterAuthResult",
|
|
219
221
|
"TwitterAuthStatusResponse",
|
|
220
222
|
"TwitterIntegrationConfigResponse",
|
|
@@ -338,6 +340,7 @@
|
|
|
338
340
|
"tool_names_list",
|
|
339
341
|
"tool_permission_simulate",
|
|
340
342
|
"trust_rules_list",
|
|
343
|
+
"twilio_config",
|
|
341
344
|
"twitter_auth_start",
|
|
342
345
|
"twitter_auth_status",
|
|
343
346
|
"twitter_integration_config",
|
|
@@ -462,6 +465,7 @@
|
|
|
462
465
|
"tool_use_start",
|
|
463
466
|
"trace_event",
|
|
464
467
|
"trust_rules_list_response",
|
|
468
|
+
"twilio_config_response",
|
|
465
469
|
"twitter_auth_result",
|
|
466
470
|
"twitter_auth_status_response",
|
|
467
471
|
"twitter_integration_config_response",
|
|
@@ -67,6 +67,10 @@ export interface SecretResponse {
|
|
|
67
67
|
|
|
68
68
|
export interface SessionListRequest {
|
|
69
69
|
type: 'session_list';
|
|
70
|
+
/** Number of sessions to skip (for pagination). Defaults to 0. */
|
|
71
|
+
offset?: number;
|
|
72
|
+
/** Maximum number of sessions to return. Defaults to 50. */
|
|
73
|
+
limit?: number;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
/** Lightweight session transport metadata for channel identity and natural-language guidance. */
|
|
@@ -557,6 +561,7 @@ export interface TwilioConfigRequest {
|
|
|
557
561
|
phoneNumber?: string; // Only for action: 'assign_number'
|
|
558
562
|
areaCode?: string; // Only for action: 'provision_number'
|
|
559
563
|
country?: string; // Only for action: 'provision_number' (ISO 3166-1 alpha-2, default 'US')
|
|
564
|
+
assistantId?: string; // Scope number assignment/lookup to a specific assistant
|
|
560
565
|
}
|
|
561
566
|
|
|
562
567
|
export interface TwilioConfigResponse {
|
|
@@ -566,6 +571,8 @@ export interface TwilioConfigResponse {
|
|
|
566
571
|
phoneNumber?: string;
|
|
567
572
|
numbers?: Array<{ phoneNumber: string; friendlyName: string; capabilities: { voice: boolean; sms: boolean } }>;
|
|
568
573
|
error?: string;
|
|
574
|
+
/** Non-fatal warning message (e.g. webhook sync failure that did not prevent the primary operation). */
|
|
575
|
+
warning?: string;
|
|
569
576
|
}
|
|
570
577
|
|
|
571
578
|
export interface GuardianVerificationRequest {
|
|
@@ -573,6 +580,7 @@ export interface GuardianVerificationRequest {
|
|
|
573
580
|
action: 'create_challenge' | 'status' | 'revoke';
|
|
574
581
|
channel?: string; // Defaults to 'telegram'
|
|
575
582
|
sessionId?: string;
|
|
583
|
+
assistantId?: string; // Defaults to 'self'
|
|
576
584
|
}
|
|
577
585
|
|
|
578
586
|
export interface GuardianVerificationResponse {
|
|
@@ -1271,6 +1279,8 @@ export interface ChannelBinding {
|
|
|
1271
1279
|
export interface SessionListResponse {
|
|
1272
1280
|
type: 'session_list_response';
|
|
1273
1281
|
sessions: Array<{ id: string; title: string; updatedAt: number; threadType?: ThreadType; channelBinding?: ChannelBinding }>;
|
|
1282
|
+
/** Whether more sessions exist beyond the returned page. */
|
|
1283
|
+
hasMore?: boolean;
|
|
1274
1284
|
}
|
|
1275
1285
|
|
|
1276
1286
|
export interface SessionsClearResponse {
|
|
@@ -58,6 +58,7 @@ import { repairHistory, deepRepairHistory } from './history-repair.js';
|
|
|
58
58
|
import { stripMediaPayloadsForRetry, raceWithTimeout } from './session-media-retry.js';
|
|
59
59
|
import { commitTurnChanges } from '../workspace/turn-commit.js';
|
|
60
60
|
import { getWorkspaceGitService } from '../workspace/git-service.js';
|
|
61
|
+
import { commitAppTurnChanges } from '../memory/app-git-service.js';
|
|
61
62
|
import type { UsageActor } from '../usage/actors.js';
|
|
62
63
|
import type { SkillProjectionCache } from './session-skill-tools.js';
|
|
63
64
|
|
|
@@ -859,6 +860,9 @@ export async function runAgentLoopImpl(
|
|
|
859
860
|
'Turn-boundary commit timed out — continuing without waiting (commit still runs in background)',
|
|
860
861
|
);
|
|
861
862
|
}
|
|
863
|
+
|
|
864
|
+
// Commit app changes (fire-and-forget — apps repo is separate from workspace)
|
|
865
|
+
void commitAppTurnChanges(ctx.conversationId, ctx.turnCount);
|
|
862
866
|
}
|
|
863
867
|
|
|
864
868
|
ctx.profiler.emitSummary(ctx.traceEmitter, reqId);
|
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Message } from '../providers/types.js';
|
|
10
10
|
import type { ServerMessage, UserMessageAttachment } from './ipc-protocol.js';
|
|
11
|
+
import type { UsageStats } from './ipc-contract.js';
|
|
11
12
|
import type { MessageQueue } from './session-queue-manager.js';
|
|
12
13
|
import type { QueueDrainReason } from './session-queue-manager.js';
|
|
13
14
|
import type { TraceEmitter } from './trace-emitter.js';
|
|
14
15
|
import { createUserMessage, createAssistantMessage } from '../agent/message-types.js';
|
|
15
16
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
16
|
-
import { resolveSlash } from './session-slash.js';
|
|
17
|
+
import { resolveSlash, type SlashContext } from './session-slash.js';
|
|
17
18
|
import { getConfig } from '../config/loader.js';
|
|
18
19
|
import { getLogger } from '../util/logger.js';
|
|
19
20
|
import { tryRouteCallMessage } from '../calls/call-bridge.js';
|
|
@@ -56,6 +57,8 @@ export interface ProcessSessionContext {
|
|
|
56
57
|
readonly traceEmitter: TraceEmitter;
|
|
57
58
|
currentActiveSurfaceId?: string;
|
|
58
59
|
currentPage?: string;
|
|
60
|
+
/** Cumulative token usage stats for the session. */
|
|
61
|
+
readonly usageStats: UsageStats;
|
|
59
62
|
/** Request-scoped skill IDs preactivated via slash resolution. */
|
|
60
63
|
preactivatedSkillIds?: string[];
|
|
61
64
|
persistUserMessage(content: string, attachments: UserMessageAttachment[], requestId?: string, metadata?: Record<string, unknown>): string;
|
|
@@ -67,6 +70,20 @@ export interface ProcessSessionContext {
|
|
|
67
70
|
): Promise<void>;
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
/** Build a SlashContext from the current session state and config. */
|
|
74
|
+
function buildSlashContext(session: ProcessSessionContext): SlashContext {
|
|
75
|
+
const config = getConfig();
|
|
76
|
+
return {
|
|
77
|
+
messageCount: session.messages.length,
|
|
78
|
+
inputTokens: session.usageStats.inputTokens,
|
|
79
|
+
outputTokens: session.usageStats.outputTokens,
|
|
80
|
+
maxInputTokens: config.contextWindow.maxInputTokens,
|
|
81
|
+
model: config.model,
|
|
82
|
+
provider: config.provider,
|
|
83
|
+
estimatedCost: session.usageStats.estimatedCost,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
70
87
|
// ── drainQueue ───────────────────────────────────────────────────────
|
|
71
88
|
|
|
72
89
|
/**
|
|
@@ -96,7 +113,7 @@ export function drainQueue(session: ProcessSessionContext, reason: QueueDrainRea
|
|
|
96
113
|
});
|
|
97
114
|
|
|
98
115
|
// Resolve slash commands for queued messages
|
|
99
|
-
const slashResult = resolveSlash(next.content);
|
|
116
|
+
const slashResult = resolveSlash(next.content, buildSlashContext(session));
|
|
100
117
|
|
|
101
118
|
// Unknown slash — persist the exchange and continue draining.
|
|
102
119
|
// Persist each message before pushing to session.messages so that a
|
|
@@ -242,7 +259,7 @@ export async function processMessage(
|
|
|
242
259
|
session.currentPage = currentPage;
|
|
243
260
|
|
|
244
261
|
// Resolve slash commands before persistence
|
|
245
|
-
const slashResult = resolveSlash(content);
|
|
262
|
+
const slashResult = resolveSlash(content, buildSlashContext(session));
|
|
246
263
|
|
|
247
264
|
// Unknown slash command — persist the exchange (user + assistant) so the
|
|
248
265
|
// messageId is real. Persist each message before pushing to session.messages
|
|
@@ -15,6 +15,18 @@ export type SlashResolution =
|
|
|
15
15
|
| { kind: 'rewritten'; content: string; skillId: string }
|
|
16
16
|
| { kind: 'unknown'; message: string };
|
|
17
17
|
|
|
18
|
+
// ── /status command ──────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface SlashContext {
|
|
21
|
+
messageCount: number;
|
|
22
|
+
inputTokens: number;
|
|
23
|
+
outputTokens: number;
|
|
24
|
+
maxInputTokens: number;
|
|
25
|
+
model: string;
|
|
26
|
+
provider: string;
|
|
27
|
+
estimatedCost: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
// ── /model command ───────────────────────────────────────────────────
|
|
19
31
|
|
|
20
32
|
const AVAILABLE_MODELS = [
|
|
@@ -242,11 +254,31 @@ function resolveModelCommand(content: string): SlashResolution | null {
|
|
|
242
254
|
};
|
|
243
255
|
}
|
|
244
256
|
|
|
257
|
+
function resolveStatusCommand(context: SlashContext): SlashResolution {
|
|
258
|
+
const { inputTokens, maxInputTokens, model, provider, messageCount, outputTokens, estimatedCost } = context;
|
|
259
|
+
const pct = maxInputTokens > 0 ? Math.min(Math.round((inputTokens / maxInputTokens) * 100), 100) : 0;
|
|
260
|
+
const filled = Math.round(pct / 5);
|
|
261
|
+
const bar = '█'.repeat(filled) + '░'.repeat(20 - filled);
|
|
262
|
+
const fmt = (n: number) => n.toLocaleString('en-US');
|
|
263
|
+
const displayName = MODEL_DISPLAY_NAMES[model] ?? model;
|
|
264
|
+
|
|
265
|
+
const lines = [
|
|
266
|
+
'Session Status\n',
|
|
267
|
+
`Context: ${bar} ${pct}% (${fmt(inputTokens)} / ${fmt(maxInputTokens)} tokens)`,
|
|
268
|
+
`Model: ${displayName} (${provider})`,
|
|
269
|
+
`Messages: ${fmt(messageCount)}`,
|
|
270
|
+
`Tokens: ${fmt(inputTokens)} in / ${fmt(outputTokens)} out`,
|
|
271
|
+
`Cost: $${estimatedCost.toFixed(2)} (estimated)`,
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
return { kind: 'unknown', message: lines.join('\n') };
|
|
275
|
+
}
|
|
276
|
+
|
|
245
277
|
/**
|
|
246
278
|
* Resolve slash commands against the current skill catalog.
|
|
247
279
|
* Returns `unknown` with a deterministic message, or the (possibly rewritten) content.
|
|
248
280
|
*/
|
|
249
|
-
export function resolveSlash(content: string): SlashResolution {
|
|
281
|
+
export function resolveSlash(content: string, context?: SlashContext): SlashResolution {
|
|
250
282
|
// Check provider shortcuts first (/gpt4, /opus, etc.)
|
|
251
283
|
const providerResult = resolveProviderModelCommand(content);
|
|
252
284
|
if (providerResult) return providerResult;
|
|
@@ -255,11 +287,27 @@ export function resolveSlash(content: string): SlashResolution {
|
|
|
255
287
|
const modelResult = resolveModelCommand(content);
|
|
256
288
|
if (modelResult) return modelResult;
|
|
257
289
|
|
|
290
|
+
// Handle /status command
|
|
291
|
+
if (content.trim() === '/status') {
|
|
292
|
+
if (!context) {
|
|
293
|
+
return { kind: 'unknown', message: 'Status information is not available in this context.' };
|
|
294
|
+
}
|
|
295
|
+
return resolveStatusCommand(context);
|
|
296
|
+
}
|
|
297
|
+
|
|
258
298
|
// Handle /commands command
|
|
259
299
|
if (content.trim() === '/commands') {
|
|
300
|
+
const lines = [
|
|
301
|
+
'/commands — List all available commands',
|
|
302
|
+
'/model — Show or switch the current model',
|
|
303
|
+
'/models — List all available models',
|
|
304
|
+
];
|
|
305
|
+
if (context) {
|
|
306
|
+
lines.push('/status — Show session status and context usage');
|
|
307
|
+
}
|
|
260
308
|
return {
|
|
261
309
|
kind: 'unknown',
|
|
262
|
-
message: '
|
|
310
|
+
message: lines.join('\n'),
|
|
263
311
|
};
|
|
264
312
|
}
|
|
265
313
|
|
|
@@ -429,6 +429,12 @@ export function refreshSurfacesForApp(ctx: SurfaceSessionContext, appId: string,
|
|
|
429
429
|
};
|
|
430
430
|
stored.data = updatedData;
|
|
431
431
|
|
|
432
|
+
// Keep the persisted snapshot in sync so updates survive session restart.
|
|
433
|
+
const idx = ctx.currentTurnSurfaces.findIndex(s => s.surfaceId === surfaceId);
|
|
434
|
+
if (idx !== -1) {
|
|
435
|
+
ctx.currentTurnSurfaces[idx].data = updatedData;
|
|
436
|
+
}
|
|
437
|
+
|
|
432
438
|
// Push the update to the client
|
|
433
439
|
ctx.sendToClient({
|
|
434
440
|
type: 'ui_surface_update',
|
|
@@ -603,6 +609,13 @@ export async function surfaceProxyResolver(
|
|
|
603
609
|
surfaceId,
|
|
604
610
|
data: mergedData,
|
|
605
611
|
});
|
|
612
|
+
|
|
613
|
+
// Keep the persisted snapshot in sync so updates survive session restart.
|
|
614
|
+
const idx = ctx.currentTurnSurfaces.findIndex(s => s.surfaceId === surfaceId);
|
|
615
|
+
if (idx !== -1) {
|
|
616
|
+
ctx.currentTurnSurfaces[idx].data = mergedData;
|
|
617
|
+
}
|
|
618
|
+
|
|
606
619
|
return { content: 'Surface updated', isError: false };
|
|
607
620
|
}
|
|
608
621
|
|
|
@@ -662,7 +675,10 @@ export async function surfaceProxyResolver(
|
|
|
662
675
|
const seededHomeBase = findSeededHomeBaseApp();
|
|
663
676
|
const defaultPreview = seededHomeBase && seededHomeBase.id === app.id
|
|
664
677
|
? getPrebuiltHomeBasePreview()
|
|
665
|
-
|
|
678
|
+
// Generate a minimal fallback preview from app metadata so that the
|
|
679
|
+
// surface is always rendered as a clickable preview card (not an
|
|
680
|
+
// un-clickable fallback chip) after session restart.
|
|
681
|
+
: { title: app.name, subtitle: app.description };
|
|
666
682
|
|
|
667
683
|
const surfaceData: DynamicPageSurfaceData = {
|
|
668
684
|
html: app.htmlDefinition,
|
|
@@ -73,11 +73,20 @@ export function getPublicBaseUrl(config: IngressConfig): string {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* Build the Twilio voice webhook URL
|
|
76
|
+
* Build the Twilio voice webhook URL.
|
|
77
|
+
*
|
|
78
|
+
* When `callSessionId` is provided (outbound calls), it is included as a
|
|
79
|
+
* query parameter so the gateway can correlate the webhook to an existing
|
|
80
|
+
* session. When omitted (phone-number-level webhook configuration for
|
|
81
|
+
* inbound calls), the URL is returned without the query parameter — the
|
|
82
|
+
* gateway will create a new session for inbound calls.
|
|
77
83
|
*/
|
|
78
|
-
export function getTwilioVoiceWebhookUrl(config: IngressConfig, callSessionId
|
|
84
|
+
export function getTwilioVoiceWebhookUrl(config: IngressConfig, callSessionId?: string): string {
|
|
79
85
|
const base = getPublicBaseUrl(config);
|
|
80
|
-
|
|
86
|
+
if (callSessionId) {
|
|
87
|
+
return `${base}/webhooks/twilio/voice?callSessionId=${callSessionId}`;
|
|
88
|
+
}
|
|
89
|
+
return `${base}/webhooks/twilio/voice`;
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
/**
|
|
@@ -114,6 +123,14 @@ export function getOAuthCallbackUrl(config: IngressConfig): string {
|
|
|
114
123
|
return `${base}/webhooks/oauth/callback`;
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Build the Twilio SMS webhook URL.
|
|
128
|
+
*/
|
|
129
|
+
export function getTwilioSmsWebhookUrl(config: IngressConfig): string {
|
|
130
|
+
const base = getPublicBaseUrl(config);
|
|
131
|
+
return `${base}/webhooks/twilio/sms`;
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
/**
|
|
118
135
|
* Build the Telegram webhook URL.
|
|
119
136
|
*/
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { Command } from 'commander';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
6
|
-
import { spawn } from 'node:child_process';
|
|
7
5
|
|
|
8
6
|
const require = createRequire(import.meta.url);
|
|
9
7
|
const { version } = require('../package.json') as { version: string };
|
|
@@ -58,24 +56,4 @@ registerCompletionsCommand(program);
|
|
|
58
56
|
registerTwitterCommand(program);
|
|
59
57
|
registerMapCommand(program);
|
|
60
58
|
|
|
61
|
-
|
|
62
|
-
const firstArg = process.argv[2];
|
|
63
|
-
|
|
64
|
-
if (firstArg && !firstArg.startsWith('-') && !knownCommands.has(firstArg)) {
|
|
65
|
-
try {
|
|
66
|
-
const cliPkgPath = require.resolve('@vellumai/cli/package.json');
|
|
67
|
-
const cliEntry = join(dirname(cliPkgPath), 'src', 'index.ts');
|
|
68
|
-
const child = spawn('bun', ['run', cliEntry, ...process.argv.slice(2)], {
|
|
69
|
-
stdio: 'inherit',
|
|
70
|
-
});
|
|
71
|
-
child.on('exit', (code) => {
|
|
72
|
-
process.exit(code ?? 1);
|
|
73
|
-
});
|
|
74
|
-
} catch {
|
|
75
|
-
console.error(`Unknown command: ${firstArg}`);
|
|
76
|
-
console.error('Install the full stack with: bun install -g vellum');
|
|
77
|
-
process.exit(1);
|
|
78
|
-
}
|
|
79
|
-
} else {
|
|
80
|
-
program.parse();
|
|
81
|
-
}
|
|
59
|
+
program.parse();
|
|
@@ -141,6 +141,30 @@ export async function commitAppChange(message: string): Promise<void> {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Commit app changes at turn boundaries.
|
|
146
|
+
*
|
|
147
|
+
* Called once per agent turn (after all tool calls complete). Only creates
|
|
148
|
+
* a commit if there are actual changes in the apps directory, so multiple
|
|
149
|
+
* mutations within a single turn are batched into one version.
|
|
150
|
+
*
|
|
151
|
+
* Fire-and-forget safe: errors are logged but never thrown.
|
|
152
|
+
*/
|
|
153
|
+
export async function commitAppTurnChanges(sessionId: string, turnNumber: number): Promise<void> {
|
|
154
|
+
try {
|
|
155
|
+
const appsDir = getAppsDir();
|
|
156
|
+
ensureAppGitignoreRules(appsDir);
|
|
157
|
+
|
|
158
|
+
const gitService = getWorkspaceGitService(appsDir);
|
|
159
|
+
await gitService.commitIfDirty(() => ({
|
|
160
|
+
message: `Turn ${turnNumber}: app changes`,
|
|
161
|
+
metadata: { sessionId, turnNumber },
|
|
162
|
+
}));
|
|
163
|
+
} catch (err) {
|
|
164
|
+
log.error({ err, sessionId, turnNumber }, 'Failed to commit app turn changes');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
144
168
|
// ---------------------------------------------------------------------------
|
|
145
169
|
// Query methods
|
|
146
170
|
// ---------------------------------------------------------------------------
|
package/src/memory/app-store.ts
CHANGED
|
@@ -29,7 +29,6 @@ import {
|
|
|
29
29
|
isPrebuiltHomeBaseApp,
|
|
30
30
|
validatePrebuiltHomeBaseHtml,
|
|
31
31
|
} from '../home-base/prebuilt-home-base-updater.js';
|
|
32
|
-
import { commitAppChange } from './app-git-service.js';
|
|
33
32
|
|
|
34
33
|
export interface AppDefinition {
|
|
35
34
|
id: string;
|
|
@@ -211,8 +210,6 @@ export function createApp(params: {
|
|
|
211
210
|
app.pages = params.pages;
|
|
212
211
|
}
|
|
213
212
|
|
|
214
|
-
void commitAppChange(`Create app: ${params.name}`);
|
|
215
|
-
|
|
216
213
|
return app;
|
|
217
214
|
}
|
|
218
215
|
|
|
@@ -375,27 +372,14 @@ export function updateApp(
|
|
|
375
372
|
updated.pages = loadedPages;
|
|
376
373
|
}
|
|
377
374
|
|
|
378
|
-
const changedFields = Object.keys(updates).filter(k => updates[k as keyof typeof updates] !== undefined);
|
|
379
|
-
void commitAppChange(`Update app: ${updated.name}\n\nChanged: ${changedFields.join(', ')}`);
|
|
380
|
-
|
|
381
375
|
return updated;
|
|
382
376
|
}
|
|
383
377
|
|
|
384
378
|
export function deleteApp(id: string): void {
|
|
385
379
|
validateId(id);
|
|
386
380
|
const dir = getAppsDir();
|
|
387
|
-
|
|
388
|
-
// Read app name before deleting for the commit message
|
|
389
|
-
let appName = id;
|
|
390
381
|
const filePath = join(dir, `${id}.json`);
|
|
391
382
|
if (existsSync(filePath)) {
|
|
392
|
-
try {
|
|
393
|
-
const raw = readFileSync(filePath, 'utf-8');
|
|
394
|
-
const app = JSON.parse(raw);
|
|
395
|
-
if (app.name) appName = app.name;
|
|
396
|
-
} catch {
|
|
397
|
-
// fall back to id
|
|
398
|
-
}
|
|
399
383
|
unlinkSync(filePath);
|
|
400
384
|
}
|
|
401
385
|
const previewPath = join(dir, `${id}.preview`);
|
|
@@ -404,8 +388,6 @@ export function deleteApp(id: string): void {
|
|
|
404
388
|
}
|
|
405
389
|
const appDir = join(dir, id);
|
|
406
390
|
rmSync(appDir, { recursive: true, force: true });
|
|
407
|
-
|
|
408
|
-
void commitAppChange(`Delete app: ${appName}`);
|
|
409
391
|
}
|
|
410
392
|
|
|
411
393
|
export function createAppRecord(appId: string, data: Record<string, unknown>): AppRecord {
|
|
@@ -545,8 +527,6 @@ export function writeAppFile(appId: string, path: string, content: string): void
|
|
|
545
527
|
const dir = join(resolved, '..');
|
|
546
528
|
mkdirSync(dir, { recursive: true });
|
|
547
529
|
writeFileSync(resolved, content, 'utf-8');
|
|
548
|
-
|
|
549
|
-
void commitAppChange(`Write ${path} in app ${appId}`);
|
|
550
530
|
}
|
|
551
531
|
|
|
552
532
|
/**
|
|
@@ -569,7 +549,6 @@ export function editAppFile(
|
|
|
569
549
|
const result = applyEdit(content, oldString, newString, replaceAll ?? false);
|
|
570
550
|
if (result.ok) {
|
|
571
551
|
writeFileSync(resolved, result.updatedContent, 'utf-8');
|
|
572
|
-
void commitAppChange(`Edit ${path} in app ${appId}`);
|
|
573
552
|
}
|
|
574
553
|
return result;
|
|
575
554
|
}
|
|
@@ -15,7 +15,7 @@ import { eq, and, lte, isNotNull } from 'drizzle-orm';
|
|
|
15
15
|
import { v4 as uuid } from 'uuid';
|
|
16
16
|
import { getDb } from './db.js';
|
|
17
17
|
import { channelInboundEvents, conversations } from './schema.js';
|
|
18
|
-
import { getOrCreateConversation } from './conversation-key-store.js';
|
|
18
|
+
import { getConversationByKey, getOrCreateConversation, setConversationKeyIfAbsent } from './conversation-key-store.js';
|
|
19
19
|
import {
|
|
20
20
|
classifyError,
|
|
21
21
|
retryDelayForAttempt,
|
|
@@ -31,6 +31,7 @@ export interface InboundResult {
|
|
|
31
31
|
|
|
32
32
|
export interface RecordInboundOptions {
|
|
33
33
|
sourceMessageId?: string;
|
|
34
|
+
assistantId?: string;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -69,8 +70,30 @@ export function recordInbound(
|
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
const
|
|
73
|
-
const
|
|
73
|
+
const assistantId = options?.assistantId;
|
|
74
|
+
const legacyKey = `${sourceChannel}:${externalChatId}`;
|
|
75
|
+
const scopedKey = assistantId ? `asst:${assistantId}:${sourceChannel}:${externalChatId}` : legacyKey;
|
|
76
|
+
|
|
77
|
+
// Resolve conversation mapping with assistant-scoped keying:
|
|
78
|
+
// 1. If scoped key exists, use it directly.
|
|
79
|
+
// 2. If assistantId is "self" and legacy key exists, reuse the legacy
|
|
80
|
+
// conversation and create a scoped alias to prevent future bleed.
|
|
81
|
+
// 3. Otherwise, create/get conversation from the scoped key.
|
|
82
|
+
let mapping: { conversationId: string; created: boolean };
|
|
83
|
+
const scopedMapping = assistantId ? getConversationByKey(scopedKey) : null;
|
|
84
|
+
if (scopedMapping) {
|
|
85
|
+
mapping = { conversationId: scopedMapping.conversationId, created: false };
|
|
86
|
+
} else if (assistantId === 'self') {
|
|
87
|
+
const legacyMapping = getConversationByKey(legacyKey);
|
|
88
|
+
if (legacyMapping) {
|
|
89
|
+
mapping = { conversationId: legacyMapping.conversationId, created: false };
|
|
90
|
+
setConversationKeyIfAbsent(scopedKey, legacyMapping.conversationId);
|
|
91
|
+
} else {
|
|
92
|
+
mapping = getOrCreateConversation(scopedKey);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
mapping = getOrCreateConversation(scopedKey);
|
|
96
|
+
}
|
|
74
97
|
const now = Date.now();
|
|
75
98
|
const eventId = uuid();
|
|
76
99
|
|
|
@@ -316,6 +339,54 @@ export function getDeadLetterEvents(): Array<{
|
|
|
316
339
|
.all();
|
|
317
340
|
}
|
|
318
341
|
|
|
342
|
+
// ── Deliver-once guard for terminal reply idempotency ────────────────
|
|
343
|
+
//
|
|
344
|
+
// When both the main poll (processChannelMessageWithApprovals) and the
|
|
345
|
+
// post-decision poll (schedulePostDecisionDelivery) race to deliver the
|
|
346
|
+
// final assistant reply for the same run, this guard ensures only one
|
|
347
|
+
// of them actually sends the message. The guard is run-scoped so old
|
|
348
|
+
// assistant messages from previous runs are not affected.
|
|
349
|
+
|
|
350
|
+
const deliveredRuns = new Set<string>();
|
|
351
|
+
|
|
352
|
+
/** TTL for delivery claims — 10 minutes, well beyond the poll max-wait. */
|
|
353
|
+
const CLAIM_TTL_MS = 10 * 60 * 1000;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Atomically claim the right to deliver the final reply for a run.
|
|
357
|
+
* Returns `true` if this caller won the claim (and should proceed with
|
|
358
|
+
* delivery). Returns `false` if another caller already claimed it.
|
|
359
|
+
*
|
|
360
|
+
* This is an in-memory guard — sufficient because both racing pollers
|
|
361
|
+
* execute within the same process. The Set is never persisted; on restart
|
|
362
|
+
* there are no in-flight pollers to race.
|
|
363
|
+
*
|
|
364
|
+
* Claims are automatically evicted after CLAIM_TTL_MS to prevent
|
|
365
|
+
* unbounded Set growth over the lifetime of the process.
|
|
366
|
+
*/
|
|
367
|
+
export function claimRunDelivery(runId: string): boolean {
|
|
368
|
+
if (deliveredRuns.has(runId)) return false;
|
|
369
|
+
deliveredRuns.add(runId);
|
|
370
|
+
setTimeout(() => deliveredRuns.delete(runId), CLAIM_TTL_MS);
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Reset the deliver-once guard for a run. Used to release a claim when
|
|
376
|
+
* delivery fails (so the other racing poller can retry) and in tests
|
|
377
|
+
* for isolation between test cases.
|
|
378
|
+
*/
|
|
379
|
+
export function resetRunDeliveryClaim(runId: string): void {
|
|
380
|
+
deliveredRuns.delete(runId);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Clear all delivery claims. Used in tests for full isolation.
|
|
385
|
+
*/
|
|
386
|
+
export function resetAllRunDeliveryClaims(): void {
|
|
387
|
+
deliveredRuns.clear();
|
|
388
|
+
}
|
|
389
|
+
|
|
319
390
|
/**
|
|
320
391
|
* Reset dead-lettered events back to 'failed' so the sweep can retry
|
|
321
392
|
* them. Resets attempt counter and sets an immediate retry_after.
|