@vellumai/assistant 0.3.19 → 0.3.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +151 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/docs/architecture/integrations.md +7 -11
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +439 -108
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +124 -0
- package/src/__tests__/guardian-grant-minting.test.ts +6 -17
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +57 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +6 -6
- package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +252 -209
- package/src/calls/call-domain.ts +44 -6
- package/src/calls/guardian-dispatch.ts +48 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +46 -30
- package/src/cli/core-commands.ts +0 -4
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +1 -1
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +6 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +0 -1
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +6 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +258 -432
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +0 -2
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +5 -1
- package/src/memory/embedding-local.ts +13 -8
- package/src/memory/guardian-action-store.ts +125 -2
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +2 -1
- package/src/memory/schema.ts +5 -1
- package/src/memory/scoped-approval-grants.ts +14 -5
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +92 -35
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -190
- package/src/runtime/routes/inbound-message-handler.ts +486 -394
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +1 -1
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +6 -0
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- package/src/tools/terminal/backends/docker.ts +0 -379
|
@@ -66,7 +66,7 @@ const DEFAULT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
|
66
66
|
// Helpers
|
|
67
67
|
// ---------------------------------------------------------------------------
|
|
68
68
|
|
|
69
|
-
function hashToken(rawToken: string): string {
|
|
69
|
+
export function hashToken(rawToken: string): string {
|
|
70
70
|
return createHash('sha256').update(rawToken).digest('hex');
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -268,6 +268,12 @@ export function redeemInvite(params: {
|
|
|
268
268
|
return { error: 'invite_max_uses_reached' };
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
// Enforce channel-scoped redemption: when the caller specifies a channel, it
|
|
272
|
+
// must match the channel the invite was created for.
|
|
273
|
+
if (params.sourceChannel && params.sourceChannel !== invite.sourceChannel) {
|
|
274
|
+
return { error: 'invite_channel_mismatch' };
|
|
275
|
+
}
|
|
276
|
+
|
|
271
277
|
const newUseCount = invite.useCount + 1;
|
|
272
278
|
const newStatus = newUseCount >= invite.maxUses ? 'redeemed' : 'active';
|
|
273
279
|
|
|
@@ -323,6 +329,94 @@ export function redeemInvite(params: {
|
|
|
323
329
|
return { invite: updatedInvite, member: rowToMember(memberRow) };
|
|
324
330
|
}
|
|
325
331
|
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// recordInviteUse — consume one use without creating a member row
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Increment an invite's use count and record redemption metadata without
|
|
338
|
+
* inserting a new member row. Used when reactivating an existing inactive
|
|
339
|
+
* member via invite — the member row already exists and just needs an
|
|
340
|
+
* update, so the transactional INSERT in `redeemInvite` would hit a
|
|
341
|
+
* unique-key constraint.
|
|
342
|
+
*
|
|
343
|
+
* Returns `true` if the use was recorded, or `false` if the invite was
|
|
344
|
+
* concurrently revoked/expired (the WHERE clause constrains to
|
|
345
|
+
* `status = 'active'` so a stale write is impossible).
|
|
346
|
+
*/
|
|
347
|
+
export function recordInviteUse(params: {
|
|
348
|
+
inviteId: string;
|
|
349
|
+
externalUserId?: string;
|
|
350
|
+
externalChatId?: string;
|
|
351
|
+
}): boolean {
|
|
352
|
+
const db = getDb();
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
|
|
355
|
+
const invite = db
|
|
356
|
+
.select()
|
|
357
|
+
.from(assistantIngressInvites)
|
|
358
|
+
.where(eq(assistantIngressInvites.id, params.inviteId))
|
|
359
|
+
.get();
|
|
360
|
+
|
|
361
|
+
if (!invite) return false;
|
|
362
|
+
|
|
363
|
+
const newUseCount = invite.useCount + 1;
|
|
364
|
+
const newStatus = newUseCount >= invite.maxUses ? 'redeemed' : 'active';
|
|
365
|
+
|
|
366
|
+
// Constrain the update to active invites so a concurrent revoke/expire
|
|
367
|
+
// prevents this write rather than silently overwriting the new status.
|
|
368
|
+
db.update(assistantIngressInvites)
|
|
369
|
+
.set({
|
|
370
|
+
useCount: newUseCount,
|
|
371
|
+
status: newStatus,
|
|
372
|
+
redeemedByExternalUserId: params.externalUserId ?? null,
|
|
373
|
+
redeemedByExternalChatId: params.externalChatId ?? null,
|
|
374
|
+
redeemedAt: now,
|
|
375
|
+
updatedAt: now,
|
|
376
|
+
})
|
|
377
|
+
.where(
|
|
378
|
+
and(
|
|
379
|
+
eq(assistantIngressInvites.id, invite.id),
|
|
380
|
+
eq(assistantIngressInvites.status, 'active'),
|
|
381
|
+
),
|
|
382
|
+
)
|
|
383
|
+
.run();
|
|
384
|
+
|
|
385
|
+
// Re-read to confirm the update took effect (the WHERE clause constrains
|
|
386
|
+
// to status = 'active', so a concurrent revoke/expire would prevent it).
|
|
387
|
+
const updated = db
|
|
388
|
+
.select({ useCount: assistantIngressInvites.useCount })
|
|
389
|
+
.from(assistantIngressInvites)
|
|
390
|
+
.where(eq(assistantIngressInvites.id, invite.id))
|
|
391
|
+
.get();
|
|
392
|
+
|
|
393
|
+
return !!updated && updated.useCount === newUseCount;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// markInviteExpired
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Transition an invite's status to 'expired' in storage. This is safe to call
|
|
402
|
+
* even if the invite is already expired — the WHERE clause scopes the update
|
|
403
|
+
* to 'active' rows so it becomes a no-op in that case.
|
|
404
|
+
*/
|
|
405
|
+
export function markInviteExpired(inviteId: string): void {
|
|
406
|
+
const db = getDb();
|
|
407
|
+
const now = Date.now();
|
|
408
|
+
|
|
409
|
+
db.update(assistantIngressInvites)
|
|
410
|
+
.set({ status: 'expired', updatedAt: now })
|
|
411
|
+
.where(
|
|
412
|
+
and(
|
|
413
|
+
eq(assistantIngressInvites.id, inviteId),
|
|
414
|
+
eq(assistantIngressInvites.status, 'active'),
|
|
415
|
+
),
|
|
416
|
+
)
|
|
417
|
+
.run();
|
|
418
|
+
}
|
|
419
|
+
|
|
326
420
|
// ---------------------------------------------------------------------------
|
|
327
421
|
// findByTokenHash
|
|
328
422
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { DrizzleDb } from '../db-connection.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Add supersession metadata columns to guardian_action_requests and
|
|
5
|
+
* create an index for efficient pending-request lookups by call session.
|
|
6
|
+
*
|
|
7
|
+
* - superseded_by_request_id: links to the request that replaced this one
|
|
8
|
+
* - superseded_at: timestamp when supersession occurred
|
|
9
|
+
* - Index (call_session_id, status, created_at DESC) for fast lookups of
|
|
10
|
+
* the most recent pending request per call session
|
|
11
|
+
*
|
|
12
|
+
* The existing expired_reason column already supports 'superseded' as a
|
|
13
|
+
* value — this migration adds the structural metadata to track the
|
|
14
|
+
* supersession chain.
|
|
15
|
+
*/
|
|
16
|
+
export function migrateGuardianActionSupersession(database: DrizzleDb): void {
|
|
17
|
+
try { database.run(/*sql*/ `ALTER TABLE guardian_action_requests ADD COLUMN superseded_by_request_id TEXT`); } catch { /* already exists */ }
|
|
18
|
+
try { database.run(/*sql*/ `ALTER TABLE guardian_action_requests ADD COLUMN superseded_at INTEGER`); } catch { /* already exists */ }
|
|
19
|
+
|
|
20
|
+
database.run(
|
|
21
|
+
/*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_action_requests_session_status_created ON guardian_action_requests(call_session_id, status, created_at DESC)`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -33,9 +33,10 @@ export { migrateGuardianActionFollowup } from './030-guardian-action-followup.js
|
|
|
33
33
|
export { migrateGuardianVerificationPurpose } from './030-guardian-verification-purpose.js';
|
|
34
34
|
export { migrateConversationsThreadTypeIndex } from './031-conversations-thread-type-index.js';
|
|
35
35
|
export { migrateGuardianDeliveryConversationIndex } from './032-guardian-delivery-conversation-index.js';
|
|
36
|
+
export { migrateNotificationDeliveryThreadDecision } from './032-notification-delivery-thread-decision.js';
|
|
36
37
|
export { createScopedApprovalGrantsTable } from './033-scoped-approval-grants.js';
|
|
37
38
|
export { migrateGuardianActionToolMetadata } from './034-guardian-action-tool-metadata.js';
|
|
38
|
-
export {
|
|
39
|
+
export { migrateGuardianActionSupersession } from './035-guardian-action-supersession.js';
|
|
39
40
|
export { createCoreTables } from './100-core-tables.js';
|
|
40
41
|
export { createWatchersAndLogsTables } from './101-watchers-and-logs.js';
|
|
41
42
|
export { addCoreColumns } from './102-alter-table-columns.js';
|
package/src/memory/schema.ts
CHANGED
|
@@ -845,9 +845,13 @@ export const guardianActionRequests = sqliteTable('guardian_action_requests', {
|
|
|
845
845
|
followupCompletedAt: integer('followup_completed_at'),
|
|
846
846
|
toolName: text('tool_name'), // tool identity for tool-approval requests
|
|
847
847
|
inputDigest: text('input_digest'), // canonical SHA-256 digest of tool input
|
|
848
|
+
supersededByRequestId: text('superseded_by_request_id'), // links to the request that replaced this one
|
|
849
|
+
supersededAt: integer('superseded_at'), // epoch ms when supersession occurred
|
|
848
850
|
createdAt: integer('created_at').notNull(),
|
|
849
851
|
updatedAt: integer('updated_at').notNull(),
|
|
850
|
-
})
|
|
852
|
+
}, (table) => [
|
|
853
|
+
index('idx_guardian_action_requests_session_status_created').on(table.callSessionId, table.status, table.createdAt),
|
|
854
|
+
]);
|
|
851
855
|
|
|
852
856
|
// ── Guardian Action Deliveries (per-channel delivery tracking) ───────
|
|
853
857
|
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
* - Expired and revoked grants cannot be consumed.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { and, eq,
|
|
14
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
15
15
|
import { v4 as uuid } from 'uuid';
|
|
16
16
|
|
|
17
|
+
import { getLogger } from '../util/logger.js';
|
|
17
18
|
import { getDb, rawChanges } from './db.js';
|
|
18
19
|
import { scopedApprovalGrants } from './schema.js';
|
|
19
|
-
import { getLogger } from '../util/logger.js';
|
|
20
20
|
|
|
21
21
|
const log = getLogger('scoped-approval-grants');
|
|
22
22
|
|
|
@@ -104,7 +104,7 @@ export interface CreateScopedApprovalGrantParams {
|
|
|
104
104
|
expiresAt: string;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
function createScopedApprovalGrant(params: CreateScopedApprovalGrantParams): ScopedApprovalGrant {
|
|
108
108
|
const db = getDb();
|
|
109
109
|
const now = new Date().toISOString();
|
|
110
110
|
const id = uuid();
|
|
@@ -167,7 +167,7 @@ export interface ConsumeByRequestIdResult {
|
|
|
167
167
|
* given `requestId` and `assistantId`. Uses compare-and-swap on the
|
|
168
168
|
* `status` column so concurrent consumers race safely — at most one wins.
|
|
169
169
|
*/
|
|
170
|
-
|
|
170
|
+
function consumeScopedApprovalGrantByRequestId(
|
|
171
171
|
requestId: string,
|
|
172
172
|
consumingRequestId: string,
|
|
173
173
|
assistantId: string,
|
|
@@ -280,7 +280,7 @@ export interface ConsumeByToolSignatureResult {
|
|
|
280
280
|
* times before giving up. This prevents false denials when multiple matching
|
|
281
281
|
* grants exist but a concurrent consumer steals the first pick.
|
|
282
282
|
*/
|
|
283
|
-
|
|
283
|
+
function consumeScopedApprovalGrantByToolSignature(
|
|
284
284
|
params: ConsumeByToolSignatureParams,
|
|
285
285
|
): ConsumeByToolSignatureResult {
|
|
286
286
|
const db = getDb();
|
|
@@ -507,3 +507,12 @@ export function revokeScopedApprovalGrantsForContext(params: RevokeContextParams
|
|
|
507
507
|
|
|
508
508
|
return count;
|
|
509
509
|
}
|
|
510
|
+
|
|
511
|
+
// @internal — exposed for tests and the approval-primitive wrapper only.
|
|
512
|
+
// Do not import these from production code outside this package; use the
|
|
513
|
+
// approval-primitive API instead.
|
|
514
|
+
export const _internal = {
|
|
515
|
+
createScopedApprovalGrant,
|
|
516
|
+
consumeScopedApprovalGrantByRequestId,
|
|
517
|
+
consumeScopedApprovalGrantByToolSignature,
|
|
518
|
+
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type {
|
|
9
9
|
SlackApiResponse,
|
|
10
10
|
SlackAuthTestResponse,
|
|
11
|
+
SlackChatDeleteResponse,
|
|
11
12
|
SlackConversationHistoryResponse,
|
|
12
13
|
SlackConversationLeaveResponse,
|
|
13
14
|
SlackConversationMarkResponse,
|
|
@@ -188,6 +189,17 @@ export async function addReaction(
|
|
|
188
189
|
});
|
|
189
190
|
}
|
|
190
191
|
|
|
192
|
+
export async function deleteMessage(
|
|
193
|
+
token: string,
|
|
194
|
+
channel: string,
|
|
195
|
+
ts: string,
|
|
196
|
+
): Promise<SlackChatDeleteResponse> {
|
|
197
|
+
return request<SlackChatDeleteResponse>(token, 'chat.delete', undefined, {
|
|
198
|
+
channel,
|
|
199
|
+
ts,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
191
203
|
export async function leaveConversation(
|
|
192
204
|
token: string,
|
|
193
205
|
channel: string,
|
|
@@ -116,4 +116,9 @@ export type SlackReactionAddResponse = SlackApiResponse;
|
|
|
116
116
|
|
|
117
117
|
export type SlackConversationLeaveResponse = SlackApiResponse;
|
|
118
118
|
|
|
119
|
+
export interface SlackChatDeleteResponse extends SlackApiResponse {
|
|
120
|
+
channel: string;
|
|
121
|
+
ts: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
119
124
|
export type SlackConversationMarkResponse = SlackApiResponse;
|
|
@@ -16,6 +16,7 @@ import { getConfig } from '../config/loader.js';
|
|
|
16
16
|
import { createTimeout, extractToolUse, getConfiguredProvider, userMessage } from '../providers/provider-send-message.js';
|
|
17
17
|
import type { ModelIntent } from '../providers/types.js';
|
|
18
18
|
import { getLogger } from '../util/logger.js';
|
|
19
|
+
import { composeFallbackCopy } from './copy-composer.js';
|
|
19
20
|
import { createDecision } from './decisions-store.js';
|
|
20
21
|
import { getPreferenceSummary } from './preference-summary.js';
|
|
21
22
|
import type { NotificationSignal, RoutingIntent } from './signal.js';
|
|
@@ -251,17 +252,7 @@ function buildFallbackDecision(
|
|
|
251
252
|
};
|
|
252
253
|
}
|
|
253
254
|
|
|
254
|
-
const copy
|
|
255
|
-
for (const ch of selectedChannels) {
|
|
256
|
-
const fallbackBody = isHighUrgencyAction
|
|
257
|
-
? `Action required: ${signal.sourceEventName}`
|
|
258
|
-
: signal.sourceEventName;
|
|
259
|
-
copy[ch] = {
|
|
260
|
-
title: signal.sourceEventName,
|
|
261
|
-
body: fallbackBody,
|
|
262
|
-
...(ch === 'telegram' ? { deliveryText: fallbackBody } : {}),
|
|
263
|
-
};
|
|
264
|
-
}
|
|
255
|
+
const copy = composeFallbackCopy(signal, selectedChannels);
|
|
265
256
|
|
|
266
257
|
return {
|
|
267
258
|
shouldNotify: true,
|
|
@@ -452,7 +443,8 @@ export async function evaluateSignal(
|
|
|
452
443
|
const provider = getConfiguredProvider();
|
|
453
444
|
if (!provider) {
|
|
454
445
|
log.warn('Configured provider unavailable for notification decision, using fallback');
|
|
455
|
-
|
|
446
|
+
let decision = buildFallbackDecision(signal, availableChannels);
|
|
447
|
+
decision = enforceConversationAffinity(decision, signal.conversationAffinityHint);
|
|
456
448
|
decision.persistedDecisionId = persistDecision(signal, decision);
|
|
457
449
|
return decision;
|
|
458
450
|
}
|
|
@@ -466,6 +458,7 @@ export async function evaluateSignal(
|
|
|
466
458
|
decision = buildFallbackDecision(signal, availableChannels);
|
|
467
459
|
}
|
|
468
460
|
|
|
461
|
+
decision = enforceConversationAffinity(decision, signal.conversationAffinityHint);
|
|
469
462
|
decision.persistedDecisionId = persistDecision(signal, decision);
|
|
470
463
|
|
|
471
464
|
return decision;
|
|
@@ -600,6 +593,50 @@ export function enforceRoutingIntent(
|
|
|
600
593
|
return decision;
|
|
601
594
|
}
|
|
602
595
|
|
|
596
|
+
// ── Conversation affinity enforcement ───────────────────────────────────
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Enforce conversation affinity on a decision.
|
|
600
|
+
*
|
|
601
|
+
* When the signal carries a conversationAffinityHint (per-channel map of
|
|
602
|
+
* conversationId), override the decision's threadActions for those channels
|
|
603
|
+
* to `reuse_existing` with the hinted conversationId. This is a
|
|
604
|
+
* deterministic post-decision guard that prevents the LLM from routing
|
|
605
|
+
* guardian questions for the same call session to different conversations.
|
|
606
|
+
*/
|
|
607
|
+
export function enforceConversationAffinity(
|
|
608
|
+
decision: NotificationDecision,
|
|
609
|
+
affinityHint: Partial<Record<string, string>> | undefined,
|
|
610
|
+
): NotificationDecision {
|
|
611
|
+
if (!affinityHint) return decision;
|
|
612
|
+
|
|
613
|
+
const entries = Object.entries(affinityHint).filter(
|
|
614
|
+
([, conversationId]) => typeof conversationId === 'string' && conversationId.length > 0,
|
|
615
|
+
);
|
|
616
|
+
if (entries.length === 0) return decision;
|
|
617
|
+
|
|
618
|
+
const enforced = { ...decision };
|
|
619
|
+
const threadActions: Partial<Record<NotificationChannel, ThreadAction>> = {
|
|
620
|
+
...(decision.threadActions ?? {}),
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
for (const [channel, conversationId] of entries) {
|
|
624
|
+
threadActions[channel as NotificationChannel] = {
|
|
625
|
+
action: 'reuse_existing',
|
|
626
|
+
conversationId: conversationId!,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
enforced.threadActions = threadActions;
|
|
631
|
+
|
|
632
|
+
log.info(
|
|
633
|
+
{ affinityHint },
|
|
634
|
+
'Conversation affinity enforcement: overrode threadActions for hinted channels',
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
return enforced;
|
|
638
|
+
}
|
|
639
|
+
|
|
603
640
|
// ── Persistence ────────────────────────────────────────────────────────
|
|
604
641
|
|
|
605
642
|
function persistDecision(signal: NotificationSignal, decision: NotificationDecision): string | undefined {
|
|
@@ -133,6 +133,12 @@ export interface EmitSignalParams {
|
|
|
133
133
|
routingIntent?: RoutingIntent;
|
|
134
134
|
/** Free-form hints from the source for the decision engine. */
|
|
135
135
|
routingHints?: Record<string, unknown>;
|
|
136
|
+
/**
|
|
137
|
+
* Per-channel conversation affinity hint. Forces the decision engine to
|
|
138
|
+
* reuse the specified conversation for the given channel(s), bypassing
|
|
139
|
+
* LLM thread-routing judgment. Keyed by channel name, value is conversationId.
|
|
140
|
+
*/
|
|
141
|
+
conversationAffinityHint?: Partial<Record<string, string>>;
|
|
136
142
|
/** Optional deduplication key. */
|
|
137
143
|
dedupeKey?: string;
|
|
138
144
|
/**
|
|
@@ -177,6 +183,7 @@ export async function emitNotificationSignal(params: EmitSignalParams): Promise<
|
|
|
177
183
|
attentionHints: params.attentionHints,
|
|
178
184
|
routingIntent: params.routingIntent,
|
|
179
185
|
routingHints: params.routingHints,
|
|
186
|
+
conversationAffinityHint: params.conversationAffinityHint,
|
|
180
187
|
};
|
|
181
188
|
|
|
182
189
|
try {
|
|
@@ -27,4 +27,11 @@ export interface NotificationSignal {
|
|
|
27
27
|
routingIntent?: RoutingIntent;
|
|
28
28
|
/** Free-form hints from the source for the decision engine (e.g. preferred channels). */
|
|
29
29
|
routingHints?: Record<string, unknown>;
|
|
30
|
+
/**
|
|
31
|
+
* Per-channel conversation affinity hint. When set, the decision engine
|
|
32
|
+
* must force thread reuse to the specified conversation for that channel,
|
|
33
|
+
* bypassing LLM judgment. Used to enforce deterministic guardian thread
|
|
34
|
+
* affinity within a call session.
|
|
35
|
+
*/
|
|
36
|
+
conversationAffinityHint?: Partial<Record<string, string>>;
|
|
30
37
|
}
|
|
@@ -140,7 +140,8 @@ export function composeThreadSeed(
|
|
|
140
140
|
const parts: string[] = [];
|
|
141
141
|
if (copy.title && copy.title !== 'Notification') parts.push(copy.title);
|
|
142
142
|
if (copy.body) parts.push(copy.body);
|
|
143
|
-
|
|
143
|
+
const alreadyMentionsAction = parts.some((part) => /\baction required\b/i.test(part));
|
|
144
|
+
if (signal.attentionHints.requiresAction && parts.length > 0 && !alreadyMentionsAction) {
|
|
144
145
|
parts.push('Action required.');
|
|
145
146
|
}
|
|
146
147
|
if (parts.length > 0) {
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* same approval flow can be reused across transports.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { GuardianDecisionAction } from './guardian-decision-types.js';
|
|
11
|
+
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
11
13
|
// Approval actions
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
@@ -20,12 +22,20 @@ export interface ApprovalActionOption {
|
|
|
20
22
|
label: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Map `GuardianDecisionAction[]` to `ApprovalActionOption[]` so channel
|
|
27
|
+
* prompt payloads can be derived from the unified decision action set.
|
|
28
|
+
* The `action` field from GuardianDecisionAction maps to the `id` field
|
|
29
|
+
* on ApprovalActionOption (both are canonical action identifiers).
|
|
30
|
+
*/
|
|
31
|
+
export function toApprovalActionOptions(
|
|
32
|
+
actions: GuardianDecisionAction[],
|
|
33
|
+
): ApprovalActionOption[] {
|
|
34
|
+
return actions.map(a => ({
|
|
35
|
+
id: a.action as ApprovalAction,
|
|
36
|
+
label: a.label,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
29
39
|
|
|
30
40
|
// ---------------------------------------------------------------------------
|
|
31
41
|
// Approval prompt
|
|
@@ -17,7 +17,8 @@ import type {
|
|
|
17
17
|
ApprovalUIMetadata,
|
|
18
18
|
ChannelApprovalPrompt,
|
|
19
19
|
} from './channel-approval-types.js';
|
|
20
|
-
import {
|
|
20
|
+
import { toApprovalActionOptions } from './channel-approval-types.js';
|
|
21
|
+
import { buildDecisionActions, buildPlainTextFallback } from './guardian-decision-types.js';
|
|
21
22
|
import * as pendingInteractions from './pending-interactions.js';
|
|
22
23
|
|
|
23
24
|
/** Summary of a pending interaction, used by channel approval flows. */
|
|
@@ -69,6 +70,11 @@ export function getApprovalInfoByConversation(conversationId: string): PendingAp
|
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Internal helper: turn a PendingApprovalInfo into a ChannelApprovalPrompt.
|
|
73
|
+
*
|
|
74
|
+
* Derives actions from the shared `buildDecisionActions` builder defined in
|
|
75
|
+
* guardian-decision-types.ts, then maps them to the channel-facing
|
|
76
|
+
* `ApprovalActionOption` shape. This ensures channel button sets are always
|
|
77
|
+
* consistent with the unified `GuardianDecisionPrompt` type.
|
|
72
78
|
*/
|
|
73
79
|
function buildPromptFromApprovalInfo(info: PendingApprovalInfo): ChannelApprovalPrompt {
|
|
74
80
|
const promptText = composeApprovalMessage({
|
|
@@ -76,15 +82,11 @@ function buildPromptFromApprovalInfo(info: PendingApprovalInfo): ChannelApproval
|
|
|
76
82
|
toolName: info.toolName,
|
|
77
83
|
});
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// Plain-text fallback must remain parser-compatible (contains "yes"/"always"/"no" keywords).
|
|
85
|
-
const plainTextFallback = info.persistentDecisionsAllowed === false
|
|
86
|
-
? `${promptText}\n\nReply "yes" to approve or "no" to reject.`
|
|
87
|
-
: `${promptText}\n\nReply "yes" to approve once, "always" to approve always, or "no" to reject.`;
|
|
85
|
+
const decisionActions = buildDecisionActions({
|
|
86
|
+
persistentDecisionsAllowed: info.persistentDecisionsAllowed,
|
|
87
|
+
});
|
|
88
|
+
const actions = toApprovalActionOptions(decisionActions);
|
|
89
|
+
const plainTextFallback = buildPlainTextFallback(promptText, decisionActions);
|
|
88
90
|
|
|
89
91
|
return { promptText, actions, plainTextFallback };
|
|
90
92
|
}
|
|
@@ -199,6 +201,10 @@ export function handleChannelDecision(
|
|
|
199
201
|
* Build an approval prompt that includes context about which non-guardian
|
|
200
202
|
* user is requesting the action. Sent to the guardian's chat so they
|
|
201
203
|
* can approve or deny on behalf of the requester.
|
|
204
|
+
*
|
|
205
|
+
* Uses the shared `buildDecisionActions` builder with `forGuardianOnBehalf`
|
|
206
|
+
* set to true, which excludes `approve_always` since guardians cannot
|
|
207
|
+
* permanently allowlist tools on behalf of requesters.
|
|
202
208
|
*/
|
|
203
209
|
export function buildGuardianApprovalPrompt(
|
|
204
210
|
info: PendingApprovalInfo,
|
|
@@ -210,11 +216,9 @@ export function buildGuardianApprovalPrompt(
|
|
|
210
216
|
requesterIdentifier,
|
|
211
217
|
});
|
|
212
218
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
const plainTextFallback = `${promptText}\n\nReply "yes" to approve or "no" to reject.`;
|
|
219
|
+
const decisionActions = buildDecisionActions({ forGuardianOnBehalf: true });
|
|
220
|
+
const actions = toApprovalActionOptions(decisionActions);
|
|
221
|
+
const plainTextFallback = buildPlainTextFallback(promptText, decisionActions);
|
|
218
222
|
|
|
219
223
|
return { promptText, actions, plainTextFallback };
|
|
220
224
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel invite transport abstraction.
|
|
3
|
+
*
|
|
4
|
+
* Defines a transport interface for building shareable invite links and
|
|
5
|
+
* extracting inbound invite tokens from channel-specific payloads. Each
|
|
6
|
+
* channel (Telegram, SMS, Slack, etc.) registers an adapter that knows
|
|
7
|
+
* how to construct deep links and parse incoming tokens for that channel.
|
|
8
|
+
*
|
|
9
|
+
* The transport layer is intentionally thin: it handles URL construction
|
|
10
|
+
* and token extraction only. Redemption logic lives in
|
|
11
|
+
* `invite-redemption-service.ts`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ChannelId } from '../channels/types.js';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Types
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface InviteSharePayload {
|
|
21
|
+
/** The full URL the recipient can open to redeem the invite. */
|
|
22
|
+
url: string;
|
|
23
|
+
/** Human-readable text suitable for display alongside the link. */
|
|
24
|
+
displayText: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChannelInviteTransport {
|
|
28
|
+
/** The channel this transport handles. */
|
|
29
|
+
channel: ChannelId;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a shareable invite payload (URL + display text) from a raw token.
|
|
33
|
+
*
|
|
34
|
+
* The raw token is the base64url-encoded secret returned by
|
|
35
|
+
* `ingress-invite-store.createInvite`. The transport wraps it in a
|
|
36
|
+
* channel-specific deep link so the recipient can redeem the invite
|
|
37
|
+
* by clicking/tapping the link.
|
|
38
|
+
*/
|
|
39
|
+
buildShareableInvite(params: {
|
|
40
|
+
rawToken: string;
|
|
41
|
+
sourceChannel: ChannelId;
|
|
42
|
+
}): InviteSharePayload;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Extract an invite token from an inbound channel message.
|
|
46
|
+
*
|
|
47
|
+
* Returns the raw token string (without the `iv_` prefix) if the
|
|
48
|
+
* message contains a valid invite token, or `undefined` otherwise.
|
|
49
|
+
*/
|
|
50
|
+
extractInboundToken(params: {
|
|
51
|
+
commandIntent?: Record<string, unknown>;
|
|
52
|
+
content: string;
|
|
53
|
+
sourceMetadata?: Record<string, unknown>;
|
|
54
|
+
}): string | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Registry
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
const registry = new Map<ChannelId, ChannelInviteTransport>();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Register a channel invite transport. Overwrites any previously registered
|
|
65
|
+
* transport for the same channel.
|
|
66
|
+
*/
|
|
67
|
+
export function registerTransport(transport: ChannelInviteTransport): void {
|
|
68
|
+
registry.set(transport.channel, transport);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Look up the registered transport for a channel. Returns `undefined` when
|
|
73
|
+
* no transport has been registered for the given channel.
|
|
74
|
+
*/
|
|
75
|
+
export function getTransport(channel: ChannelId): ChannelInviteTransport | undefined {
|
|
76
|
+
return registry.get(channel);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reset the registry. Intended for tests only.
|
|
81
|
+
* @internal
|
|
82
|
+
*/
|
|
83
|
+
export function _resetRegistry(): void {
|
|
84
|
+
registry.clear();
|
|
85
|
+
}
|