@vellumai/assistant 0.3.19 → 0.3.21

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.
Files changed (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Validation guard tests for the unified feature flag registry.
3
+ *
4
+ * Ensures structural invariants hold so that both the TS and Swift loaders
5
+ * can safely consume the registry without runtime surprises.
6
+ */
7
+
8
+ import { readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function getRepoRoot(): string {
18
+ return join(process.cwd(), '..');
19
+ }
20
+
21
+ function getRegistryPath(): string {
22
+ return join(getRepoRoot(), 'meta', 'feature-flags', 'feature-flag-registry.json');
23
+ }
24
+
25
+ function loadRegistry(): Record<string, unknown> {
26
+ const raw = readFileSync(getRegistryPath(), 'utf-8');
27
+ return JSON.parse(raw);
28
+ }
29
+
30
+ const VALID_SCOPES = new Set(['assistant', 'macos']);
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Tests
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe('unified feature flag registry guard', () => {
37
+ const registry = loadRegistry();
38
+ const flags = registry.flags as Record<string, unknown>[];
39
+
40
+ // -----------------------------------------------------------------------
41
+ // version
42
+ // -----------------------------------------------------------------------
43
+
44
+ test('version is a positive integer', () => {
45
+ expect(typeof registry.version).toBe('number');
46
+ expect(Number.isInteger(registry.version)).toBe(true);
47
+ expect(registry.version as number).toBeGreaterThan(0);
48
+ });
49
+
50
+ // -----------------------------------------------------------------------
51
+ // required fields and types
52
+ // -----------------------------------------------------------------------
53
+
54
+ test('all flags have required fields with correct types', () => {
55
+ const violations: string[] = [];
56
+
57
+ for (let i = 0; i < flags.length; i++) {
58
+ const flag = flags[i];
59
+ const prefix = `flags[${i}]`;
60
+
61
+ if (typeof flag !== 'object' || !flag || Array.isArray(flag)) {
62
+ violations.push(`${prefix}: entry is not an object`);
63
+ continue;
64
+ }
65
+
66
+ if (typeof flag.id !== 'string' || flag.id.length === 0) {
67
+ violations.push(`${prefix}: missing or non-string 'id'`);
68
+ }
69
+ if (typeof flag.scope !== 'string' || flag.scope.length === 0) {
70
+ violations.push(`${prefix}: missing or non-string 'scope'`);
71
+ }
72
+ if (typeof flag.key !== 'string' || flag.key.length === 0) {
73
+ violations.push(`${prefix}: missing or non-string 'key'`);
74
+ }
75
+ if (typeof flag.label !== 'string' || flag.label.length === 0) {
76
+ violations.push(`${prefix}: missing or non-string 'label'`);
77
+ }
78
+ if (typeof flag.description !== 'string' || flag.description.length === 0) {
79
+ violations.push(`${prefix}: missing or non-string 'description'`);
80
+ }
81
+ if (typeof flag.defaultEnabled !== 'boolean') {
82
+ violations.push(`${prefix}: missing or non-boolean 'defaultEnabled'`);
83
+ }
84
+ }
85
+
86
+ if (violations.length > 0) {
87
+ const message = [
88
+ 'Found flags with missing or incorrectly-typed required fields.',
89
+ '',
90
+ 'Violations:',
91
+ ...violations.map((v) => ` - ${v}`),
92
+ ].join('\n');
93
+
94
+ expect(violations, message).toEqual([]);
95
+ }
96
+ });
97
+
98
+ // -----------------------------------------------------------------------
99
+ // unique ids
100
+ // -----------------------------------------------------------------------
101
+
102
+ test('all id values are unique', () => {
103
+ const seen = new Set<string>();
104
+ const duplicates: string[] = [];
105
+
106
+ for (const flag of flags) {
107
+ const id = flag.id as string;
108
+ if (seen.has(id)) {
109
+ duplicates.push(id);
110
+ }
111
+ seen.add(id);
112
+ }
113
+
114
+ if (duplicates.length > 0) {
115
+ const message = [
116
+ 'Found duplicate flag id values in the registry.',
117
+ '',
118
+ 'Duplicates:',
119
+ ...duplicates.map((d) => ` - ${d}`),
120
+ ].join('\n');
121
+
122
+ expect(duplicates, message).toEqual([]);
123
+ }
124
+ });
125
+
126
+ // -----------------------------------------------------------------------
127
+ // unique keys
128
+ // -----------------------------------------------------------------------
129
+
130
+ test('all key values are unique', () => {
131
+ const seen = new Set<string>();
132
+ const duplicates: string[] = [];
133
+
134
+ for (const flag of flags) {
135
+ const key = flag.key as string;
136
+ if (seen.has(key)) {
137
+ duplicates.push(key);
138
+ }
139
+ seen.add(key);
140
+ }
141
+
142
+ if (duplicates.length > 0) {
143
+ const message = [
144
+ 'Found duplicate flag key values in the registry.',
145
+ '',
146
+ 'Duplicates:',
147
+ ...duplicates.map((d) => ` - ${d}`),
148
+ ].join('\n');
149
+
150
+ expect(duplicates, message).toEqual([]);
151
+ }
152
+ });
153
+
154
+ // -----------------------------------------------------------------------
155
+ // valid scopes
156
+ // -----------------------------------------------------------------------
157
+
158
+ test('all scope values are valid', () => {
159
+ const violations: string[] = [];
160
+
161
+ for (const flag of flags) {
162
+ const scope = flag.scope as string;
163
+ if (!VALID_SCOPES.has(scope)) {
164
+ violations.push(`flag '${flag.id}' has invalid scope '${scope}' (expected 'assistant' or 'macos')`);
165
+ }
166
+ }
167
+
168
+ if (violations.length > 0) {
169
+ const message = [
170
+ 'Found flags with invalid scope values.',
171
+ '',
172
+ 'Violations:',
173
+ ...violations.map((v) => ` - ${v}`),
174
+ ].join('\n');
175
+
176
+ expect(violations, message).toEqual([]);
177
+ }
178
+ });
179
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Canonical assistant feature-flag resolver.
3
+ *
4
+ * Loads default flag values from the unified registry at
5
+ * `meta/feature-flags/feature-flag-registry.json` and resolves the effective
6
+ * enabled/disabled state for each declared assistant-scope flag by consulting
7
+ * (in priority order):
8
+ * 1. `config.assistantFeatureFlagValues[key]` (explicit override)
9
+ * 2. defaults registry `defaultEnabled` (for declared keys)
10
+ * 3. `true` (for undeclared keys)
11
+ *
12
+ * Key format:
13
+ * Canonical: `feature_flags.<id>.enabled`
14
+ */
15
+
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+
19
+ import type { AssistantConfig } from './schema.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface FeatureFlagDefault {
26
+ defaultEnabled: boolean;
27
+ description: string;
28
+ label: string;
29
+ }
30
+
31
+ export type FeatureFlagDefaultsRegistry = Record<string, FeatureFlagDefault>;
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Registry loading (singleton, loaded once)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ let cachedDefaults: FeatureFlagDefaultsRegistry | undefined;
38
+
39
+ const REGISTRY_FILENAME = 'feature-flag-registry.json';
40
+
41
+ function loadDefaultsRegistry(): FeatureFlagDefaultsRegistry {
42
+ if (cachedDefaults) return cachedDefaults;
43
+
44
+ const thisDir = import.meta.dirname ?? __dirname;
45
+ const envPath = process.env.FEATURE_FLAG_DEFAULTS_PATH?.trim();
46
+ const candidates = [
47
+ // Explicit override (primarily for tests / controlled environments)
48
+ ...(envPath ? [envPath] : []),
49
+ // Bundled: co-located copy in the same directory as this source file.
50
+ // Works in Docker / packaged builds where the repo-root `meta/` dir
51
+ // is not available.
52
+ join(thisDir, REGISTRY_FILENAME),
53
+ // Packaged macOS app layout: the daemon binary lives at
54
+ // <App>.app/Contents/MacOS/vellum-daemon and the registry is copied
55
+ // to <App>.app/Contents/Resources/ by build.sh. In bun --compile
56
+ // binaries, import.meta.dirname resolves to /$bunfs/root (virtual),
57
+ // so we need to resolve relative to the real executable path.
58
+ join(dirname(process.execPath), '..', 'Resources', REGISTRY_FILENAME),
59
+ // Development: relative to this source file's directory, walking up
60
+ // to the repo root to reach `meta/feature-flags/`.
61
+ join(thisDir, '..', '..', '..', 'meta', 'feature-flags', REGISTRY_FILENAME),
62
+ // Alternate: from repo root via cwd
63
+ join(process.cwd(), 'meta', 'feature-flags', REGISTRY_FILENAME),
64
+ ];
65
+
66
+ for (const candidate of candidates) {
67
+ if (existsSync(candidate)) {
68
+ try {
69
+ const raw = readFileSync(candidate, 'utf-8');
70
+ const parsed = JSON.parse(raw);
71
+ cachedDefaults = parseRegistryToDefaults(parsed);
72
+ return cachedDefaults;
73
+ } catch {
74
+ // Malformed file — fall through to next candidate
75
+ }
76
+ }
77
+ }
78
+
79
+ cachedDefaults = {};
80
+ return cachedDefaults;
81
+ }
82
+
83
+ /**
84
+ * Parse the unified registry JSON into a flat key -> default map,
85
+ * filtering to assistant-scope flags only.
86
+ */
87
+ function parseRegistryToDefaults(parsed: unknown): FeatureFlagDefaultsRegistry {
88
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
89
+
90
+ const registry = parsed as { version?: number; flags?: unknown[] };
91
+ if (!Array.isArray(registry.flags)) return {};
92
+
93
+ const result: FeatureFlagDefaultsRegistry = {};
94
+ for (const flag of registry.flags) {
95
+ if (!flag || typeof flag !== 'object' || Array.isArray(flag)) continue;
96
+ const entry = flag as Record<string, unknown>;
97
+ if (entry.scope !== 'assistant') continue;
98
+ if (typeof entry.key !== 'string') continue;
99
+ if (typeof entry.defaultEnabled !== 'boolean') continue;
100
+
101
+ result[entry.key as string] = {
102
+ defaultEnabled: entry.defaultEnabled,
103
+ description: typeof entry.description === 'string' ? entry.description : '',
104
+ label: typeof entry.label === 'string' ? entry.label : '',
105
+ };
106
+ }
107
+ return result;
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Public API
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Resolve whether an assistant feature flag is enabled.
116
+ *
117
+ * Resolution order:
118
+ * 1. `config.assistantFeatureFlagValues[key]` (explicit override)
119
+ * 2. defaults registry `defaultEnabled` (for declared assistant-scope keys)
120
+ * 3. `true` (for undeclared keys with no override)
121
+ */
122
+ export function isAssistantFeatureFlagEnabled(key: string, config: AssistantConfig): boolean {
123
+ const defaults = loadDefaultsRegistry();
124
+ const declared = defaults[key];
125
+
126
+ // 1. Check canonical section
127
+ const newValues = (config as AssistantConfigWithFeatureFlags).assistantFeatureFlagValues;
128
+ if (newValues) {
129
+ const explicit = newValues[key];
130
+ if (typeof explicit === 'boolean') return explicit;
131
+ }
132
+
133
+ // 2. For declared keys, use the registry default
134
+ if (declared) {
135
+ return declared.defaultEnabled;
136
+ }
137
+
138
+ // 3. Undeclared keys with no persisted override default to enabled
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * Return the loaded defaults registry (for introspection/tooling).
144
+ */
145
+ export function getAssistantFeatureFlagDefaults(): FeatureFlagDefaultsRegistry {
146
+ return loadDefaultsRegistry();
147
+ }
148
+
149
+ /**
150
+ * Reset the cached defaults registry. Intended for tests only.
151
+ */
152
+ export function _resetDefaultsCache(): void {
153
+ cachedDefaults = undefined;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Internal type augmentation for the new config field
158
+ // ---------------------------------------------------------------------------
159
+
160
+ interface AssistantConfigWithFeatureFlags extends AssistantConfig {
161
+ assistantFeatureFlagValues?: Record<string, boolean>;
162
+ }
@@ -0,0 +1,18 @@
1
+ <svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
2
+ <rect width="16" height="16" fill="#0f172a"/>
3
+ <rect x="2" y="2" width="12" height="12" fill="#1e293b"/>
4
+ <rect x="3" y="3" width="10" height="10" fill="#0f172a" stroke="#3b82f6" stroke-width="1"/>
5
+ <rect x="4" y="4" width="2" height="2" fill="#3b82f6"/>
6
+ <rect x="10" y="4" width="2" height="2" fill="#3b82f6"/>
7
+ <rect x="4" y="10" width="2" height="2" fill="#3b82f6"/>
8
+ <rect x="10" y="10" width="2" height="2" fill="#3b82f6"/>
9
+ <line x1="5" y1="5" x2="11" y2="5" stroke="#10b981" stroke-width="1"/>
10
+ <line x1="5" y1="5" x2="5" y2="11" stroke="#10b981" stroke-width="1"/>
11
+ <line x1="11" y1="5" x2="11" y2="11" stroke="#10b981" stroke-width="1"/>
12
+ <line x1="5" y1="11" x2="11" y2="11" stroke="#10b981" stroke-width="1"/>
13
+ <rect x="7" y="7" width="2" height="2" fill="#f59e0b"/>
14
+ <line x1="5" y1="8" x2="7" y2="8" stroke="#60a5fa" stroke-width="1"/>
15
+ <line x1="9" y1="8" x2="11" y2="8" stroke="#60a5fa" stroke-width="1"/>
16
+ <line x1="8" y1="5" x2="8" y2="7" stroke="#60a5fa" stroke-width="1"/>
17
+ <line x1="8" y1="9" x2="8" y2="11" stroke="#60a5fa" stroke-width="1"/>
18
+ </svg>
@@ -256,6 +256,36 @@
256
256
  "executor": "tools/slack-add-reaction.ts",
257
257
  "execution_target": "host"
258
258
  },
259
+ {
260
+ "name": "slack_delete_message",
261
+ "description": "Delete a Slack message posted by the bot. Include a confidence score (0-1).",
262
+ "category": "messaging",
263
+ "risk": "high",
264
+ "input_schema": {
265
+ "type": "object",
266
+ "properties": {
267
+ "channel": {
268
+ "type": "string",
269
+ "description": "Slack channel ID"
270
+ },
271
+ "timestamp": {
272
+ "type": "string",
273
+ "description": "Message timestamp (ts) to delete"
274
+ },
275
+ "confidence": {
276
+ "type": "number",
277
+ "description": "Confidence score (0-1) for this action"
278
+ }
279
+ },
280
+ "required": [
281
+ "channel",
282
+ "timestamp",
283
+ "confidence"
284
+ ]
285
+ },
286
+ "executor": "tools/slack-delete-message.ts",
287
+ "execution_target": "host"
288
+ },
259
289
  {
260
290
  "name": "slack_leave_channel",
261
291
  "description": "Leave a Slack channel. Include a confidence score (0-1).",
@@ -0,0 +1,24 @@
1
+ import { deleteMessage } from '../../../../messaging/providers/slack/client.js';
2
+ import { getMessagingProvider } from '../../../../messaging/registry.js';
3
+ import { withValidToken } from '../../../../security/token-manager.js';
4
+ import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
5
+ import { err, ok } from './shared.js';
6
+
7
+ export async function run(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
8
+ const channel = input.channel as string;
9
+ const timestamp = input.timestamp as string;
10
+
11
+ if (!channel || !timestamp) {
12
+ return err('channel and timestamp are both required.');
13
+ }
14
+
15
+ try {
16
+ const provider = getMessagingProvider('slack');
17
+ return withValidToken(provider.credentialService, async (token) => {
18
+ await deleteMessage(token, channel, timestamp);
19
+ return ok(`Message deleted.`);
20
+ });
21
+ } catch (e) {
22
+ return err(e instanceof Error ? e.message : String(e));
23
+ }
24
+ }
@@ -14,7 +14,7 @@ Use `send_notification` for user-facing alerts and notifications. This tool rout
14
14
 
15
15
  ## Deduplication (`dedupe_key`)
16
16
 
17
- - `dedupe_key` suppresses duplicate signals. A second notification with the same key is **dropped entirely** within a **1-hour window**. After the window expires, the same key is accepted again.
17
+ - `dedupe_key` suppresses duplicate signals **permanently**. A second notification with the same key is **dropped entirely** for the lifetime of the assistant's event store. Once a key has been used, it cannot be reused — any future notification with the same key will be silently discarded.
18
18
  - Never reuse a `dedupe_key` across logically distinct notifications, even if they are related. The key means "this exact event already fired," not "these events are in the same category."
19
19
  - If you omit `dedupe_key`, the LLM decision engine may generate one automatically based on signal context. This means even keyless signals can be deduplicated if the engine considers them duplicates of a recent event.
20
20
 
@@ -15,12 +15,34 @@ Create, list, and cancel one-time reminders. Reminders fire at a specific future
15
15
 
16
16
  Control how the reminder is delivered at trigger time with `routing_intent`:
17
17
 
18
- - **single_channel** (default) — deliver to one best channel
18
+ - **single_channel** — deliver to one best channel
19
19
  - **multi_channel** — deliver to a subset of channels
20
- - **all_channels** — deliver to every available channel
20
+ - **all_channels** (default) — deliver to every available channel
21
21
 
22
22
  Optionally pass `routing_hints` (a JSON object) to influence routing decisions (e.g. preferred channels, exclusions). When omitted, defaults to `{}`.
23
23
 
24
+ ### Routing Defaults
25
+
26
+ Use the following heuristics to pick `routing_intent`:
27
+
28
+ - **Default to `all_channels`** for most reminders. Users setting reminders usually want to be notified wherever they are, and redundant notifications are less harmful than missed ones.
29
+ - **Use `single_channel`** only when the user explicitly specifies a single channel (e.g. "remind me on Telegram") or the reminder is low-stakes and noise reduction matters.
30
+ - **Check `user_message_channel`** from the turn context. If the user is currently active on a specific channel (e.g. `vellum`), always include that channel. Pass it as a routing hint:
31
+ ```
32
+ routing_hints: { preferred_channels: ["vellum"] }
33
+ routing_intent: "all_channels"
34
+ ```
35
+ - **Never use `single_channel` as a passive default.** If you haven't thought about which channel to use, use `all_channels`.
36
+
37
+ ### Examples
38
+
39
+ | Scenario | routing_intent | routing_hints |
40
+ |---|---|---|
41
+ | User sets reminder from desktop app | `all_channels` | `{ preferred_channels: ["vellum"] }` |
42
+ | User says "remind me on Telegram" | `single_channel` | `{ preferred_channels: ["telegram"] }` |
43
+ | User sets reminder from Telegram | `all_channels` | `{ preferred_channels: ["telegram"] }` |
44
+ | No channel preference expressed | `all_channels` | `{}` |
45
+
24
46
  ## Usage Notes
25
47
 
26
48
  - Use reminders ONLY for time-triggered notifications (e.g. "remind me at 3pm", "remind me in 2 hours").
@@ -28,3 +50,28 @@ Optionally pass `routing_hints` (a JSON object) to influence routing decisions (
28
50
  - For task tracking ("add to my tasks", "add to my queue"), use task_list_add instead.
29
51
  - `fire_at` must be a strict ISO 8601 timestamp with timezone offset or Z (e.g. `2025-03-15T09:00:00-05:00` or `2025-03-15T09:00:00Z`). Ambiguous timestamps without timezone info will be rejected.
30
52
  - `label` is a short human-readable summary shown in the notification.
53
+
54
+ ### Anchored & Ambiguous Relative Time
55
+
56
+ Phrases like "at the 45 minute mark", "at the top of the hour", "on the half-hour", "at noon", "20 minutes in", or "when I hit an hour" are **clock-position or anchored relative time** expressions. Do NOT treat them as offsets from now.
57
+
58
+ **Resolution rules (in priority order):**
59
+
60
+ 1. **Clock-position expressions** — map directly to a wall-clock time:
61
+ - "top of the hour" / "on the hour" → next :00 (e.g. 10:00 AM)
62
+ - "the X minute mark" / "at :XX" → current hour's :XX; if already past, advance one hour
63
+ - "the half-hour mark" / "half past" → nearest upcoming :30
64
+ - "noon" / "midnight" → 12:00 PM or 12:00 AM today; if past, tomorrow
65
+ - "quarter past" / "quarter to" → :15 or :45 of current or next hour
66
+
67
+ 2. **Session-anchored expressions** — if the user mentioned a start time earlier in conversation ("I got here at 9", "meeting started at 2pm"), compute `start_time + offset`.
68
+
69
+ 3. **Ask only if truly ambiguous** — if neither rule 1 nor rule 2 resolves, ask: "Do you mean [clock time] or [X minutes from now]?" Never silently default to "from now."
70
+
71
+ **Examples:**
72
+ - "at the 45 min mark" (now: 9:39) → 9:45 AM
73
+ - "at the 45 min mark" (now: 9:50) → 10:45 AM
74
+ - "top of the hour" (now: 9:39) → 10:00 AM
75
+ - "at noon" → 12:00 PM today
76
+ - "20 minutes in, I started at 2pm" → 2:20 PM
77
+ - "at the hour mark" with no start time → ask for clarification
@@ -55,6 +55,31 @@ When the user says "in X minutes/hours", compute the ISO 8601 timestamp yourself
55
55
  - Format as ISO 8601 with timezone: `2025-03-15T09:05:00-05:00`
56
56
  - Pass to `reminder_create` as `fire_at`
57
57
 
58
+ ### Anchored & Ambiguous Relative Time
59
+
60
+ Phrases like "at the 45 minute mark", "at the top of the hour", "on the half-hour", "at noon", "20 minutes in", or "when I hit an hour" are **clock-position or anchored relative time** expressions. Do NOT treat them as offsets from now.
61
+
62
+ **Resolution rules (in priority order):**
63
+
64
+ 1. **Clock-position expressions** — map directly to a wall-clock time:
65
+ - "top of the hour" / "on the hour" → next :00 (e.g. 10:00 AM)
66
+ - "the X minute mark" / "at :XX" → current hour's :XX; if already past, advance one hour
67
+ - "the half-hour mark" / "half past" → nearest upcoming :30
68
+ - "noon" / "midnight" → 12:00 PM or 12:00 AM today; if past, tomorrow
69
+ - "quarter past" / "quarter to" → :15 or :45 of current or next hour
70
+
71
+ 2. **Session-anchored expressions** — if the user mentioned a start time earlier in conversation ("I got here at 9", "meeting started at 2pm"), compute `start_time + offset`.
72
+
73
+ 3. **Ask only if truly ambiguous** — if neither rule 1 nor rule 2 resolves, ask: "Do you mean [clock time] or [X minutes from now]?" Never silently default to "from now."
74
+
75
+ **Examples:**
76
+ - "at the 45 min mark" (now: 9:39) → 9:45 AM
77
+ - "at the 45 min mark" (now: 9:50) → 10:45 AM
78
+ - "top of the hour" (now: 9:39) → 10:00 AM
79
+ - "at noon" → 12:00 PM today
80
+ - "20 minutes in, I started at 2pm" → 2:20 PM
81
+ - "at the hour mark" with no start time → ask for clarification
82
+
58
83
  ## "Remind me to X" Disambiguation
59
84
 
60
85
  The word "remind" is ambiguous. Route based on whether a time is specified:
@@ -77,12 +102,34 @@ Use `notify` for simple alerts. Use `execute` when the reminder should trigger t
77
102
  ## Reminder Routing
78
103
 
79
104
  `reminder_create` supports a `routing_intent` parameter that controls how the reminder is delivered at trigger time:
80
- - **`single_channel`** (default) — deliver to one best channel
105
+ - **`single_channel`** — deliver to one best channel
81
106
  - **`multi_channel`** — deliver to a subset of channels
82
- - **`all_channels`** — deliver to every available channel
107
+ - **`all_channels`** (default) — deliver to every available channel
83
108
 
84
109
  You can also pass `routing_hints` (a JSON object) to influence routing decisions (e.g. preferred channels, exclusions).
85
110
 
111
+ ### Routing Defaults
112
+
113
+ Use the following heuristics to pick `routing_intent`:
114
+
115
+ - **Default to `all_channels`** for most reminders. Users setting reminders usually want to be notified wherever they are, and redundant notifications are less harmful than missed ones.
116
+ - **Use `single_channel`** only when the user explicitly specifies a single channel (e.g. "remind me on Telegram") or the reminder is low-stakes and noise reduction matters.
117
+ - **Check `user_message_channel`** from the turn context. If the user is currently active on a specific channel (e.g. `vellum`), always include that channel. Pass it as a routing hint:
118
+ ```
119
+ routing_hints: { preferred_channels: ["vellum"] }
120
+ routing_intent: "all_channels"
121
+ ```
122
+ - **Never use `single_channel` as a passive default.** If you haven't thought about which channel to use, use `all_channels`.
123
+
124
+ ### Examples
125
+
126
+ | Scenario | routing_intent | routing_hints |
127
+ |---|---|---|
128
+ | User sets reminder from desktop app | `all_channels` | `{ preferred_channels: ["vellum"] }` |
129
+ | User says "remind me on Telegram" | `single_channel` | `{ preferred_channels: ["telegram"] }` |
130
+ | User sets reminder from Telegram | `all_channels` | `{ preferred_channels: ["telegram"] }` |
131
+ | No channel preference expressed | `all_channels` | `{}` |
132
+
86
133
  ## Tool Summary
87
134
 
88
135
  | Tool | Timing | Recurrence | Purpose |