@vellumai/assistant 0.5.3 → 0.5.5
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/Dockerfile +18 -27
- package/docs/architecture/memory.md +105 -0
- package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
- package/package.json +1 -1
- package/src/__tests__/archive-recall.test.ts +560 -0
- package/src/__tests__/conversation-clear-safety.test.ts +259 -0
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
- package/src/__tests__/credential-security-invariants.test.ts +2 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
- package/src/__tests__/memory-reducer-job.test.ts +538 -0
- package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
- package/src/__tests__/memory-reducer-types.test.ts +12 -4
- package/src/__tests__/memory-reducer.test.ts +7 -1
- package/src/__tests__/memory-regressions.test.ts +24 -4
- package/src/__tests__/memory-simplified-config.test.ts +4 -4
- package/src/__tests__/openai-whisper.test.ts +93 -0
- package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
- package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
- package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
- package/src/__tests__/volume-security-guard.test.ts +155 -0
- package/src/cli/commands/conversations.ts +18 -0
- package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
- package/src/config/env-registry.ts +9 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/loader.ts +0 -1
- package/src/config/schemas/memory-simplified.ts +1 -1
- package/src/credential-execution/managed-catalog.ts +5 -15
- package/src/daemon/config-watcher.ts +4 -1
- package/src/daemon/conversation-memory.ts +117 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -0
- package/src/daemon/daemon-control.ts +7 -0
- package/src/daemon/handlers/conversations.ts +11 -0
- package/src/daemon/lifecycle.ts +51 -2
- package/src/daemon/providers-setup.ts +2 -1
- package/src/hooks/manager.ts +7 -0
- package/src/instrument.ts +33 -1
- package/src/memory/archive-recall.ts +516 -0
- package/src/memory/brief-time.ts +5 -4
- package/src/memory/conversation-crud.ts +210 -0
- package/src/memory/conversation-key-store.ts +33 -4
- package/src/memory/db-init.ts +4 -0
- package/src/memory/embedding-local.ts +11 -5
- package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
- package/src/memory/job-handlers/conversation-starters.ts +24 -30
- package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
- package/src/memory/jobs-store.ts +2 -0
- package/src/memory/jobs-worker.ts +8 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
- package/src/memory/migrations/141-rename-verification-table.ts +8 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
- package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
- package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/reducer-scheduler.ts +242 -0
- package/src/memory/reducer-types.ts +9 -2
- package/src/memory/reducer.ts +25 -11
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/slack/adapter.ts +29 -2
- package/src/oauth/connection-resolver.test.ts +22 -18
- package/src/oauth/connection-resolver.ts +92 -7
- package/src/oauth/platform-connection.test.ts +78 -69
- package/src/oauth/platform-connection.ts +12 -19
- package/src/permissions/trust-client.ts +343 -0
- package/src/permissions/trust-store-interface.ts +105 -0
- package/src/permissions/trust-store.ts +523 -36
- package/src/platform/client.test.ts +148 -0
- package/src/platform/client.ts +71 -0
- package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
- package/src/providers/speech-to-text/openai-whisper.ts +68 -0
- package/src/providers/speech-to-text/resolve.ts +9 -0
- package/src/providers/speech-to-text/types.ts +17 -0
- package/src/runtime/auth/route-policy.ts +10 -1
- package/src/runtime/http-server.ts +2 -2
- package/src/runtime/routes/conversation-management-routes.ts +88 -2
- package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
- package/src/runtime/routes/inbound-message-handler.ts +27 -3
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
- package/src/runtime/routes/log-export-routes.ts +1 -0
- package/src/runtime/routes/secret-routes.ts +5 -1
- package/src/schedule/schedule-store.ts +7 -0
- package/src/schedule/scheduler.ts +6 -2
- package/src/security/ces-credential-client.ts +173 -0
- package/src/security/secure-keys.ts +65 -22
- package/src/signals/bash.ts +3 -0
- package/src/signals/cancel.ts +3 -0
- package/src/signals/confirm.ts +3 -0
- package/src/signals/conversation-undo.ts +3 -0
- package/src/signals/event-stream.ts +7 -0
- package/src/signals/shotgun.ts +3 -0
- package/src/signals/trust-rule.ts +3 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
- package/src/telemetry/usage-telemetry-reporter.ts +22 -20
- package/src/tools/filesystem/edit.ts +6 -1
- package/src/tools/filesystem/read.ts +6 -1
- package/src/tools/filesystem/write.ts +6 -1
- package/src/tools/memory/handlers.ts +129 -1
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +5 -1
- package/src/tools/schedule/update.ts +6 -0
- package/src/util/device-id.ts +70 -7
- package/src/util/logger.ts +35 -9
- package/src/util/platform.ts +29 -5
- package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
- package/src/workspace/migrations/registry.ts +2 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reducer scheduler — synchronous pre-switch/create reduction of the most
|
|
3
|
+
* recently updated dirty conversation.
|
|
4
|
+
*
|
|
5
|
+
* When the user switches conversations or starts a new one, we want the
|
|
6
|
+
* *previous* conversation's memory to be reduced before the next memory
|
|
7
|
+
* read. This module exposes {@link reduceBeforeSwitch} which:
|
|
8
|
+
*
|
|
9
|
+
* 1. Finds the single most recently updated dirty conversation (excluding
|
|
10
|
+
* the target conversation).
|
|
11
|
+
* 2. Runs the same reduction pipeline the background job uses (load
|
|
12
|
+
* unreduced messages, call {@link runReducer}, apply via
|
|
13
|
+
* {@link applyReducerResult}).
|
|
14
|
+
* 3. Awaits the result so the caller can proceed knowing memory is fresh.
|
|
15
|
+
*
|
|
16
|
+
* If no eligible dirty conversation exists, the function returns immediately.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { and, asc, desc, eq, gte, isNotNull, ne } from "drizzle-orm";
|
|
20
|
+
|
|
21
|
+
import { getLogger } from "../util/logger.js";
|
|
22
|
+
import { type ConversationRow, getConversation } from "./conversation-crud.js";
|
|
23
|
+
import { getDb } from "./db.js";
|
|
24
|
+
import { type ReducerPromptInput, runReducer } from "./reducer.js";
|
|
25
|
+
import {
|
|
26
|
+
applyReducerResult,
|
|
27
|
+
getActiveOpenLoops,
|
|
28
|
+
getActiveTimeContexts,
|
|
29
|
+
} from "./reducer-store.js";
|
|
30
|
+
import { EMPTY_REDUCER_RESULT } from "./reducer-types.js";
|
|
31
|
+
import { conversations, messages } from "./schema.js";
|
|
32
|
+
|
|
33
|
+
const log = getLogger("reducer-scheduler");
|
|
34
|
+
|
|
35
|
+
// ── Internal helpers ────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
interface MessageRow {
|
|
38
|
+
id: string;
|
|
39
|
+
role: string;
|
|
40
|
+
content: string;
|
|
41
|
+
createdAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Find the single most recently updated dirty conversation, excluding
|
|
46
|
+
* the target conversation. Returns the conversation ID or null if none.
|
|
47
|
+
*/
|
|
48
|
+
export function findMostRecentDirtyConversation(
|
|
49
|
+
excludeConversationId: string,
|
|
50
|
+
): string | null {
|
|
51
|
+
const db = getDb();
|
|
52
|
+
const row = db
|
|
53
|
+
.select({ id: conversations.id })
|
|
54
|
+
.from(conversations)
|
|
55
|
+
.where(
|
|
56
|
+
and(
|
|
57
|
+
isNotNull(conversations.memoryDirtyTailSinceMessageId),
|
|
58
|
+
ne(conversations.id, excludeConversationId),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
.orderBy(desc(conversations.updatedAt))
|
|
62
|
+
.limit(1)
|
|
63
|
+
.get();
|
|
64
|
+
|
|
65
|
+
return row?.id ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load messages from `dirtyTailMessageId` onward (inclusive), ordered by
|
|
70
|
+
* createdAt ascending.
|
|
71
|
+
*/
|
|
72
|
+
function loadUnreducedMessages(
|
|
73
|
+
conversationId: string,
|
|
74
|
+
dirtyTailMessageId: string,
|
|
75
|
+
): MessageRow[] {
|
|
76
|
+
const db = getDb();
|
|
77
|
+
|
|
78
|
+
const tailMessage = db
|
|
79
|
+
.select({ createdAt: messages.createdAt })
|
|
80
|
+
.from(messages)
|
|
81
|
+
.where(eq(messages.id, dirtyTailMessageId))
|
|
82
|
+
.get();
|
|
83
|
+
|
|
84
|
+
if (!tailMessage) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return db
|
|
89
|
+
.select({
|
|
90
|
+
id: messages.id,
|
|
91
|
+
role: messages.role,
|
|
92
|
+
content: messages.content,
|
|
93
|
+
createdAt: messages.createdAt,
|
|
94
|
+
})
|
|
95
|
+
.from(messages)
|
|
96
|
+
.where(
|
|
97
|
+
and(
|
|
98
|
+
eq(messages.conversationId, conversationId),
|
|
99
|
+
gte(messages.createdAt, tailMessage.createdAt),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
.orderBy(asc(messages.createdAt))
|
|
103
|
+
.all();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the `newMessages` array for the reducer input, optionally
|
|
108
|
+
* prepending the conversation's contextSummary as a synthetic system message.
|
|
109
|
+
*/
|
|
110
|
+
function buildNewMessages(
|
|
111
|
+
conversation: ConversationRow,
|
|
112
|
+
unreducedMessages: MessageRow[],
|
|
113
|
+
): Array<{ role: string; content: string }> {
|
|
114
|
+
const result: Array<{ role: string; content: string }> = [];
|
|
115
|
+
|
|
116
|
+
if (conversation.contextSummary) {
|
|
117
|
+
result.push({
|
|
118
|
+
role: "system",
|
|
119
|
+
content: `[Prior context summary] ${conversation.contextSummary}`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const msg of unreducedMessages) {
|
|
124
|
+
result.push({ role: msg.role, content: msg.content });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Reduce the most recently updated dirty conversation (excluding
|
|
134
|
+
* `targetConversationId`) before a conversation switch or create.
|
|
135
|
+
*
|
|
136
|
+
* This runs the full reduction pipeline synchronously (awaiting the
|
|
137
|
+
* provider call) so the caller can proceed knowing memory is fresh.
|
|
138
|
+
*
|
|
139
|
+
* Returns the conversation ID that was reduced, or null if none were eligible.
|
|
140
|
+
*/
|
|
141
|
+
export async function reduceBeforeSwitch(
|
|
142
|
+
targetConversationId: string,
|
|
143
|
+
): Promise<string | null> {
|
|
144
|
+
const dirtyConversationId =
|
|
145
|
+
findMostRecentDirtyConversation(targetConversationId);
|
|
146
|
+
|
|
147
|
+
if (!dirtyConversationId) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const conversation = getConversation(dirtyConversationId);
|
|
152
|
+
if (!conversation) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const dirtyTailMessageId = conversation.memoryDirtyTailSinceMessageId;
|
|
157
|
+
if (!dirtyTailMessageId) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Load unreduced messages ──────────────────────────────────
|
|
162
|
+
const unreducedMessages = loadUnreducedMessages(
|
|
163
|
+
dirtyConversationId,
|
|
164
|
+
dirtyTailMessageId,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (unreducedMessages.length === 0) {
|
|
168
|
+
log.debug(
|
|
169
|
+
{ conversationId: dirtyConversationId, dirtyTailMessageId },
|
|
170
|
+
"No messages found from dirty tail — nothing to reduce on switch",
|
|
171
|
+
);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Load active brief-state context ──────────────────────────
|
|
176
|
+
const scopeId = conversation.memoryScopeId;
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
|
|
179
|
+
const existingTimeContexts = getActiveTimeContexts(scopeId, now);
|
|
180
|
+
const existingOpenLoops = getActiveOpenLoops(scopeId);
|
|
181
|
+
|
|
182
|
+
// ── Build reducer input ──────────────────────────────────────
|
|
183
|
+
const newMessages = buildNewMessages(conversation, unreducedMessages);
|
|
184
|
+
|
|
185
|
+
const reducerInput: ReducerPromptInput = {
|
|
186
|
+
conversationId: dirtyConversationId,
|
|
187
|
+
newMessages,
|
|
188
|
+
existingTimeContexts: existingTimeContexts.map((tc) => ({
|
|
189
|
+
id: tc.id,
|
|
190
|
+
summary: tc.summary,
|
|
191
|
+
})),
|
|
192
|
+
existingOpenLoops: existingOpenLoops.map((ol) => ({
|
|
193
|
+
id: ol.id,
|
|
194
|
+
summary: ol.summary,
|
|
195
|
+
status: ol.status,
|
|
196
|
+
})),
|
|
197
|
+
nowMs: now,
|
|
198
|
+
scopeId,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// ── Run the reducer ──────────────────────────────────────────
|
|
202
|
+
try {
|
|
203
|
+
const result = await runReducer(reducerInput);
|
|
204
|
+
|
|
205
|
+
if (result === EMPTY_REDUCER_RESULT) {
|
|
206
|
+
log.debug(
|
|
207
|
+
{ conversationId: dirtyConversationId },
|
|
208
|
+
"Reducer returned empty result on switch — not advancing checkpoint",
|
|
209
|
+
);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Apply result transactionally ───────────────────────────
|
|
214
|
+
const lastMessage = unreducedMessages[unreducedMessages.length - 1];
|
|
215
|
+
applyReducerResult({
|
|
216
|
+
result,
|
|
217
|
+
conversationId: dirtyConversationId,
|
|
218
|
+
scopeId,
|
|
219
|
+
reducedThroughMessageId: lastMessage.id,
|
|
220
|
+
now,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
log.info(
|
|
224
|
+
{
|
|
225
|
+
conversationId: dirtyConversationId,
|
|
226
|
+
reducedThroughMessageId: lastMessage.id,
|
|
227
|
+
messageCount: unreducedMessages.length,
|
|
228
|
+
timeContextOps: result.timeContexts.length,
|
|
229
|
+
openLoopOps: result.openLoops.length,
|
|
230
|
+
},
|
|
231
|
+
"Pre-switch memory reduction completed",
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return dirtyConversationId;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
log.warn(
|
|
237
|
+
{ err, conversationId: dirtyConversationId },
|
|
238
|
+
"Pre-switch memory reduction failed — continuing with switch",
|
|
239
|
+
);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -86,8 +86,15 @@ export interface ReducerResult {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
89
|
+
* Sentinel empty result returned when the reducer output is **unparseable**
|
|
90
|
+
* (not valid JSON, not a JSON object, provider failure, etc.).
|
|
91
|
+
*
|
|
92
|
+
* Callers use identity comparison (`=== EMPTY_REDUCER_RESULT`) to detect
|
|
93
|
+
* true parse failures and skip checkpoint advancement so the job can retry.
|
|
94
|
+
*
|
|
95
|
+
* A valid-but-empty model response (e.g. `{}`) returns a normal
|
|
96
|
+
* `ReducerResult` with all empty arrays — NOT this sentinel — so the
|
|
97
|
+
* checkpoint advances and the dirty tail is cleared.
|
|
91
98
|
*/
|
|
92
99
|
export const EMPTY_REDUCER_RESULT: Readonly<ReducerResult> = Object.freeze({
|
|
93
100
|
timeContexts: Object.freeze([]) as unknown as TimeContextOp[],
|
package/src/memory/reducer.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 1. ReducerPromptInput — structured input for the provider call
|
|
6
6
|
* 2. runReducer — send the transcript span to the LLM and return a typed result
|
|
7
7
|
* 3. parseReducerOutput — raw string -> validated ReducerResult
|
|
8
|
-
* 4. Fallback to EMPTY_REDUCER_RESULT on
|
|
8
|
+
* 4. Fallback to EMPTY_REDUCER_RESULT on unparseable output (parse failures only)
|
|
9
9
|
*
|
|
10
10
|
* The reducer is intentionally side-effect-free: it never writes to the
|
|
11
11
|
* database. Callers are responsible for applying the returned ReducerResult.
|
|
@@ -373,13 +373,19 @@ function validateArchiveEpisode(raw: unknown): ArchiveEpisodeCandidate | null {
|
|
|
373
373
|
/**
|
|
374
374
|
* Parse raw model output into a validated ReducerResult.
|
|
375
375
|
*
|
|
376
|
-
* On any structural error (non-JSON,
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
376
|
+
* On any structural error (non-JSON, not a JSON object) the function returns
|
|
377
|
+
* {@link EMPTY_REDUCER_RESULT} rather than throwing — callers use identity
|
|
378
|
+
* comparison (`=== EMPTY_REDUCER_RESULT`) to detect true parse failures and
|
|
379
|
+
* skip checkpoint advancement.
|
|
380
380
|
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
381
|
+
* A valid JSON object with no recognized top-level arrays (e.g. `{}`) is
|
|
382
|
+
* treated as a **valid-but-empty** response — the model simply had nothing
|
|
383
|
+
* to extract. In this case a normal `ReducerResult` with all empty arrays
|
|
384
|
+
* is returned so that callers advance the checkpoint and clear the dirty
|
|
385
|
+
* tail, avoiding an infinite retry loop.
|
|
386
|
+
*
|
|
387
|
+
* Individual invalid operations within an otherwise valid structure are
|
|
388
|
+
* silently dropped to preserve the rest of the result.
|
|
383
389
|
*/
|
|
384
390
|
export function parseReducerOutput(raw: string): ReducerResult {
|
|
385
391
|
let parsed: unknown;
|
|
@@ -399,22 +405,30 @@ export function parseReducerOutput(raw: string): ReducerResult {
|
|
|
399
405
|
|
|
400
406
|
const obj = parsed as Record<string, unknown>;
|
|
401
407
|
|
|
402
|
-
// Check
|
|
408
|
+
// Check which top-level array keys are present
|
|
403
409
|
const hasTimeContexts = Array.isArray(obj.timeContexts);
|
|
404
410
|
const hasOpenLoops = Array.isArray(obj.openLoops);
|
|
405
411
|
const hasArchiveObservations = Array.isArray(obj.archiveObservations);
|
|
406
412
|
const hasArchiveEpisodes = Array.isArray(obj.archiveEpisodes);
|
|
407
413
|
|
|
414
|
+
// A valid JSON object with no recognized arrays (e.g. `{}`) means the
|
|
415
|
+
// model had nothing to extract — return a normal (non-sentinel) empty
|
|
416
|
+
// result so the checkpoint advances.
|
|
408
417
|
if (
|
|
409
418
|
!hasTimeContexts &&
|
|
410
419
|
!hasOpenLoops &&
|
|
411
420
|
!hasArchiveObservations &&
|
|
412
421
|
!hasArchiveEpisodes
|
|
413
422
|
) {
|
|
414
|
-
log.
|
|
415
|
-
"reducer output
|
|
423
|
+
log.debug(
|
|
424
|
+
"reducer output is valid JSON with no extractions — advancing with empty result",
|
|
416
425
|
);
|
|
417
|
-
return
|
|
426
|
+
return {
|
|
427
|
+
timeContexts: [],
|
|
428
|
+
openLoops: [],
|
|
429
|
+
archiveObservations: [],
|
|
430
|
+
archiveEpisodes: [],
|
|
431
|
+
};
|
|
418
432
|
}
|
|
419
433
|
|
|
420
434
|
const timeContexts: TimeContextOp[] = [];
|
|
@@ -24,6 +24,7 @@ export const cronJobs = sqliteTable("cron_jobs", {
|
|
|
24
24
|
routingIntent: text("routing_intent").notNull().default("all_channels"), // 'single_channel' | 'multi_channel' | 'all_channels'
|
|
25
25
|
routingHintsJson: text("routing_hints_json").notNull().default("{}"),
|
|
26
26
|
status: text("status").notNull().default("active"), // 'active' | 'firing' | 'fired' | 'cancelled'
|
|
27
|
+
quiet: integer("quiet", { mode: "boolean" }).notNull().default(false), // suppress completion notifications
|
|
27
28
|
createdAt: integer("created_at").notNull(),
|
|
28
29
|
updatedAt: integer("updated_at").notNull(),
|
|
29
30
|
});
|
|
@@ -89,6 +89,15 @@ export interface MessagingProvider {
|
|
|
89
89
|
*/
|
|
90
90
|
isConnected?(): Promise<boolean>;
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Custom credential resolution for providers with non-standard credential
|
|
94
|
+
* paths (e.g. Slack Socket Mode stores tokens under "slack_channel" rather
|
|
95
|
+
* than the OAuth provider key). When present, getProviderConnection() calls
|
|
96
|
+
* this instead of resolveOAuthConnection(), giving the provider full control
|
|
97
|
+
* over credential lookup including fallback strategies.
|
|
98
|
+
*/
|
|
99
|
+
resolveConnection?(account?: string): Promise<OAuthConnection | string>;
|
|
100
|
+
|
|
92
101
|
/** Platform-specific capabilities for tool routing (e.g. 'reactions', 'threads', 'labels'). */
|
|
93
102
|
capabilities: Set<string>;
|
|
94
103
|
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Slack messaging provider adapter.
|
|
3
3
|
*
|
|
4
|
-
* Maps Slack API responses to the platform-agnostic messaging types
|
|
5
|
-
*
|
|
4
|
+
* Maps Slack API responses to the platform-agnostic messaging types and
|
|
5
|
+
* implements the MessagingProvider interface.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { OAuthConnection } from "../../../oauth/connection.js";
|
|
9
|
+
import { resolveOAuthConnection } from "../../../oauth/connection-resolver.js";
|
|
10
|
+
import { isProviderConnected } from "../../../oauth/oauth-store.js";
|
|
11
|
+
import { credentialKey } from "../../../security/credential-key.js";
|
|
12
|
+
import { getSecureKeyAsync } from "../../../security/secure-keys.js";
|
|
9
13
|
import type { MessagingProvider } from "../../provider.js";
|
|
10
14
|
import type {
|
|
11
15
|
ConnectionInfo,
|
|
@@ -112,6 +116,29 @@ export const slackProvider: MessagingProvider = {
|
|
|
112
116
|
credentialService: "integration:slack",
|
|
113
117
|
capabilities: new Set(["reactions", "threads", "leave_channel"]),
|
|
114
118
|
|
|
119
|
+
async isConnected(): Promise<boolean> {
|
|
120
|
+
// Socket Mode: check for bot token directly in credential store.
|
|
121
|
+
// The token is the source of truth; the slack_channel connection row
|
|
122
|
+
// is advisory (backfill can fail non-fatally on startup).
|
|
123
|
+
const botToken = await getSecureKeyAsync(
|
|
124
|
+
credentialKey("slack_channel", "bot_token"),
|
|
125
|
+
);
|
|
126
|
+
if (botToken) return true;
|
|
127
|
+
// Preserve existing OAuth path (integration:slack) for backwards compat.
|
|
128
|
+
return isProviderConnected("integration:slack");
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async resolveConnection(account?: string): Promise<OAuthConnection | string> {
|
|
132
|
+
// Socket Mode: return raw bot token if available.
|
|
133
|
+
// Token presence is sufficient — no connection row required.
|
|
134
|
+
const botToken = await getSecureKeyAsync(
|
|
135
|
+
credentialKey("slack_channel", "bot_token"),
|
|
136
|
+
);
|
|
137
|
+
if (botToken) return botToken;
|
|
138
|
+
// Preserve existing OAuth path (integration:slack) for backwards compat.
|
|
139
|
+
return resolveOAuthConnection("integration:slack", { account });
|
|
140
|
+
},
|
|
141
|
+
|
|
115
142
|
async testConnection(
|
|
116
143
|
connectionOrToken: OAuthConnection | string,
|
|
117
144
|
): Promise<ConnectionInfo> {
|
|
@@ -8,12 +8,7 @@ let mockProvider: Record<string, unknown> | undefined;
|
|
|
8
8
|
let mockConnection: Record<string, unknown> | undefined;
|
|
9
9
|
let mockAccessToken: string | undefined;
|
|
10
10
|
let mockConfig: Record<string, unknown> = {};
|
|
11
|
-
let
|
|
12
|
-
enabled: false,
|
|
13
|
-
platformBaseUrl: "",
|
|
14
|
-
assistantApiKey: "",
|
|
15
|
-
};
|
|
16
|
-
let mockAssistantId = "";
|
|
11
|
+
let mockPlatformClient: Record<string, unknown> | null = null;
|
|
17
12
|
|
|
18
13
|
// ---------------------------------------------------------------------------
|
|
19
14
|
// Module mocks (must precede imports of the module under test)
|
|
@@ -48,12 +43,10 @@ mock.module("../config/loader.js", () => ({
|
|
|
48
43
|
getConfig: () => mockConfig,
|
|
49
44
|
}));
|
|
50
45
|
|
|
51
|
-
mock.module("../
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
mock.module("../providers/managed-proxy/context.js", () => ({
|
|
56
|
-
resolveManagedProxyContext: async () => mockManagedProxyCtx,
|
|
46
|
+
mock.module("../platform/client.js", () => ({
|
|
47
|
+
VellumPlatformClient: {
|
|
48
|
+
create: async () => mockPlatformClient,
|
|
49
|
+
},
|
|
57
50
|
}));
|
|
58
51
|
|
|
59
52
|
// ---------------------------------------------------------------------------
|
|
@@ -68,6 +61,22 @@ import { PlatformOAuthConnection } from "./platform-connection.js";
|
|
|
68
61
|
// Helpers
|
|
69
62
|
// ---------------------------------------------------------------------------
|
|
70
63
|
|
|
64
|
+
function makeMockClient() {
|
|
65
|
+
return {
|
|
66
|
+
baseUrl: "https://platform.example.com",
|
|
67
|
+
assistantApiKey: "sk-test-key",
|
|
68
|
+
platformAssistantId: "asst-123",
|
|
69
|
+
fetch: mock(async () => {
|
|
70
|
+
return new Response(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
results: [{ id: "platform-conn-1", account_label: null }],
|
|
73
|
+
}),
|
|
74
|
+
{ status: 200 },
|
|
75
|
+
);
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
71
80
|
function setupDefaults(): void {
|
|
72
81
|
mockProvider = {
|
|
73
82
|
providerKey: "integration:google",
|
|
@@ -100,12 +109,7 @@ function setupDefaults(): void {
|
|
|
100
109
|
"google-oauth": { mode: "managed" },
|
|
101
110
|
},
|
|
102
111
|
};
|
|
103
|
-
|
|
104
|
-
enabled: true,
|
|
105
|
-
platformBaseUrl: "https://platform.example.com",
|
|
106
|
-
assistantApiKey: "sk-test-key",
|
|
107
|
-
};
|
|
108
|
-
mockAssistantId = "asst-123";
|
|
112
|
+
mockPlatformClient = makeMockClient();
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
// ---------------------------------------------------------------------------
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { getPlatformAssistantId } from "../config/env.js";
|
|
2
1
|
import { getConfig } from "../config/loader.js";
|
|
3
2
|
import { type Services, ServicesSchema } from "../config/schemas/services.js";
|
|
4
|
-
import {
|
|
3
|
+
import { VellumPlatformClient } from "../platform/client.js";
|
|
5
4
|
import { getSecureKeyAsync } from "../security/secure-keys.js";
|
|
5
|
+
import { getLogger } from "../util/logger.js";
|
|
6
6
|
import { BYOOAuthConnection } from "./byo-connection.js";
|
|
7
7
|
import type { OAuthConnection } from "./connection.js";
|
|
8
8
|
import { getActiveConnection, getProvider } from "./oauth-store.js";
|
|
9
9
|
import { PlatformOAuthConnection } from "./platform-connection.js";
|
|
10
10
|
|
|
11
|
+
const log = getLogger("connection-resolver");
|
|
12
|
+
|
|
11
13
|
export interface ResolveOAuthConnectionOptions {
|
|
12
14
|
/** OAuth app client ID — narrows to a specific app when multiple BYO apps
|
|
13
15
|
* exist for the same provider. */
|
|
@@ -46,16 +48,32 @@ export async function resolveOAuthConnection(
|
|
|
46
48
|
if (managedKey && managedKey in ServicesSchema.shape) {
|
|
47
49
|
const services: Services = getConfig().services;
|
|
48
50
|
if (services[managedKey as keyof Services].mode === "managed") {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
+
const client = await VellumPlatformClient.create();
|
|
52
|
+
if (!client || !client.platformAssistantId) {
|
|
53
|
+
const detail = !client
|
|
54
|
+
? "missing platform prerequisites"
|
|
55
|
+
: "missing assistant ID";
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Platform-managed connection for "${providerKey}" cannot be created: ${detail}. ` +
|
|
58
|
+
`Log in to the Vellum platform or switch to using your own OAuth app.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const providerSlug = providerKey.replace(/^integration:/, "");
|
|
63
|
+
|
|
64
|
+
const connectionId = await resolvePlatformConnectionId({
|
|
65
|
+
client,
|
|
66
|
+
provider: providerSlug,
|
|
67
|
+
account,
|
|
68
|
+
});
|
|
69
|
+
|
|
51
70
|
return new PlatformOAuthConnection({
|
|
52
71
|
id: providerKey,
|
|
53
72
|
providerKey,
|
|
54
73
|
externalId: providerKey,
|
|
55
74
|
accountInfo: account ?? null,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
apiKey: ctx.assistantApiKey,
|
|
75
|
+
client,
|
|
76
|
+
connectionId,
|
|
59
77
|
});
|
|
60
78
|
}
|
|
61
79
|
}
|
|
@@ -98,3 +116,70 @@ export async function resolveOAuthConnection(
|
|
|
98
116
|
accountInfo: conn.accountInfo,
|
|
99
117
|
});
|
|
100
118
|
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Platform connection ID resolution
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
interface ResolvePlatformConnectionIdOptions {
|
|
125
|
+
client: VellumPlatformClient;
|
|
126
|
+
provider: string;
|
|
127
|
+
account?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Fetch the platform-side connection ID for a managed provider by calling
|
|
132
|
+
* the List Connections endpoint.
|
|
133
|
+
*/
|
|
134
|
+
async function resolvePlatformConnectionId(
|
|
135
|
+
options: ResolvePlatformConnectionIdOptions,
|
|
136
|
+
): Promise<string> {
|
|
137
|
+
const { client, provider, account } = options;
|
|
138
|
+
|
|
139
|
+
const params = new URLSearchParams();
|
|
140
|
+
params.set("provider", provider);
|
|
141
|
+
params.set("status", "ACTIVE");
|
|
142
|
+
if (account) {
|
|
143
|
+
params.set("account_identifier", account);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const path = `/v1/assistants/${client.platformAssistantId}/oauth/connections/?${params.toString()}`;
|
|
147
|
+
const response = await client.fetch(path);
|
|
148
|
+
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
log.error(
|
|
151
|
+
{ status: response.status, provider },
|
|
152
|
+
"Failed to list platform OAuth connections",
|
|
153
|
+
);
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Failed to resolve platform connection for "${provider}": HTTP ${response.status}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const body = (await response.json()) as {
|
|
160
|
+
results?: Array<{ id: string; account_label?: string }>;
|
|
161
|
+
};
|
|
162
|
+
const connections = body.results ?? [];
|
|
163
|
+
|
|
164
|
+
if (connections.length === 0) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`No active platform OAuth connection found for provider "${provider}"` +
|
|
167
|
+
(account ? ` with account "${account}"` : "") +
|
|
168
|
+
". Connect the service on the Vellum platform first.",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (connections.length > 1 && !account) {
|
|
173
|
+
log.warn(
|
|
174
|
+
{
|
|
175
|
+
provider,
|
|
176
|
+
count: connections.length,
|
|
177
|
+
selectedId: connections[0].id,
|
|
178
|
+
},
|
|
179
|
+
"Multiple active platform connections found; using the most recently created. " +
|
|
180
|
+
"Pass an account option to select a specific connection.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return connections[0].id;
|
|
185
|
+
}
|