@vellumai/assistant 0.3.3 → 0.3.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.
Files changed (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Clip generation tool — extract a video segment from a media asset.
3
+ *
4
+ * Uses ffmpeg to cut a segment with configurable pre/post-roll padding,
5
+ * then registers the resulting clip as an attachment for in-chat delivery.
6
+ * This is a generic media-processing primitive with no domain-specific logic.
7
+ */
8
+
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { mkdir, unlink, stat, rmdir } from 'node:fs/promises';
12
+ import { randomUUID } from 'node:crypto';
13
+ import { readFile } from 'node:fs/promises';
14
+ import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
15
+ import { getMediaAssetById } from '../../../../memory/media-store.js';
16
+ import { uploadAttachment } from '../../../../memory/attachments-store.js';
17
+
18
+ const FFMPEG_TIMEOUT_MS = 300_000;
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function spawnWithTimeout(
25
+ cmd: string[],
26
+ timeoutMs: number,
27
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
28
+ return new Promise((resolve, reject) => {
29
+ const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe' });
30
+ const timer = setTimeout(() => {
31
+ proc.kill();
32
+ reject(new Error(`Process timed out after ${timeoutMs}ms: ${cmd[0]}`));
33
+ }, timeoutMs);
34
+ proc.exited.then(async (exitCode) => {
35
+ clearTimeout(timer);
36
+ const stdout = await new Response(proc.stdout).text();
37
+ const stderr = await new Response(proc.stderr).text();
38
+ resolve({ exitCode, stdout, stderr });
39
+ });
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Get the duration of a media file in seconds via ffprobe.
45
+ */
46
+ async function getMediaDuration(filePath: string): Promise<number> {
47
+ const result = await spawnWithTimeout([
48
+ 'ffprobe', '-v', 'error',
49
+ '-show_entries', 'format=duration',
50
+ '-of', 'csv=p=0',
51
+ filePath,
52
+ ], 10_000);
53
+ if (result.exitCode !== 0) return 0;
54
+ return parseFloat(result.stdout.trim()) || 0;
55
+ }
56
+
57
+ function formatTimestamp(seconds: number): string {
58
+ const hrs = Math.floor(seconds / 3600);
59
+ const mins = Math.floor((seconds % 3600) / 60);
60
+ const secs = Math.floor(seconds % 60);
61
+ return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
62
+ }
63
+
64
+ const MIME_BY_FORMAT: Record<string, string> = {
65
+ mp4: 'video/mp4',
66
+ webm: 'video/webm',
67
+ mov: 'video/quicktime',
68
+ };
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Tool entry point
72
+ // ---------------------------------------------------------------------------
73
+
74
+ export async function run(
75
+ input: Record<string, unknown>,
76
+ context: ToolContext,
77
+ ): Promise<ToolExecutionResult> {
78
+ const assetId = input.asset_id as string | undefined;
79
+ if (!assetId) {
80
+ return { content: 'asset_id is required.', isError: true };
81
+ }
82
+
83
+ const startTime = input.start_time as number | undefined;
84
+ if (startTime === undefined || startTime === null) {
85
+ return { content: 'start_time is required (seconds).', isError: true };
86
+ }
87
+
88
+ const endTime = input.end_time as number | undefined;
89
+ if (endTime === undefined || endTime === null) {
90
+ return { content: 'end_time is required (seconds).', isError: true };
91
+ }
92
+
93
+ if (endTime <= startTime) {
94
+ return { content: 'end_time must be greater than start_time.', isError: true };
95
+ }
96
+
97
+ const preRoll = (input.pre_roll as number) ?? 3;
98
+ const postRoll = (input.post_roll as number) ?? 2;
99
+ const outputFormat = (input.output_format as string) ?? 'mp4';
100
+
101
+ const asset = getMediaAssetById(assetId);
102
+ if (!asset) {
103
+ return { content: `Media asset not found: ${assetId}`, isError: true };
104
+ }
105
+
106
+ if (asset.mediaType !== 'video') {
107
+ return { content: `Clip generation requires a video asset. Got: ${asset.mediaType}`, isError: true };
108
+ }
109
+
110
+ // Get the file duration so we can clamp pre/post-roll to file boundaries
111
+ const fileDuration = asset.durationSeconds ?? await getMediaDuration(asset.filePath);
112
+
113
+ // Calculate actual clip boundaries with pre/post-roll, clamped to file
114
+ const clipStart = Math.max(0, startTime - preRoll);
115
+ const clipEnd = fileDuration > 0 ? Math.min(fileDuration, endTime + postRoll) : endTime + postRoll;
116
+ const clipDuration = clipEnd - clipStart;
117
+
118
+ // Prepare output path
119
+ const clipDir = join(tmpdir(), `vellum-clips-${randomUUID()}`);
120
+ await mkdir(clipDir, { recursive: true });
121
+
122
+ const clipFilename = `clip-${formatTimestamp(startTime).replace(/:/g, '')}-${formatTimestamp(endTime).replace(/:/g, '')}.${outputFormat}`;
123
+ const clipPath = join(clipDir, clipFilename);
124
+
125
+ try {
126
+ context.onOutput?.(`Extracting clip ${formatTimestamp(clipStart)} – ${formatTimestamp(clipEnd)} from ${asset.title}...\n`);
127
+
128
+ // Use ffmpeg to extract the segment
129
+ const ffmpegArgs = [
130
+ 'ffmpeg', '-y',
131
+ '-ss', String(clipStart),
132
+ '-i', asset.filePath,
133
+ '-t', String(clipDuration),
134
+ '-c', 'copy',
135
+ '-avoid_negative_ts', 'make_zero',
136
+ clipPath,
137
+ ];
138
+
139
+ const result = await spawnWithTimeout(ffmpegArgs, FFMPEG_TIMEOUT_MS);
140
+
141
+ if (result.exitCode !== 0) {
142
+ return {
143
+ content: `ffmpeg clip extraction failed: ${result.stderr.slice(0, 500)}`,
144
+ isError: true,
145
+ };
146
+ }
147
+
148
+ // Verify the output file exists and has content
149
+ const clipStat = await stat(clipPath);
150
+ if (clipStat.size === 0) {
151
+ return { content: 'Clip extraction produced an empty file.', isError: true };
152
+ }
153
+
154
+ context.onOutput?.(`Clip extracted (${(clipStat.size / 1024 / 1024).toFixed(1)} MB). Registering as attachment...\n`);
155
+
156
+ // Read clip file and register as attachment
157
+ const clipData = await readFile(clipPath);
158
+ const clipBase64 = clipData.toString('base64');
159
+ const mimeType = MIME_BY_FORMAT[outputFormat] ?? 'video/mp4';
160
+
161
+ const attachment = uploadAttachment(clipFilename, mimeType, clipBase64);
162
+
163
+ context.onOutput?.(`Clip registered as attachment ${attachment.id}.\n`);
164
+
165
+ return {
166
+ content: JSON.stringify({
167
+ message: `Clip extracted successfully`,
168
+ attachmentId: attachment.id,
169
+ filename: clipFilename,
170
+ mimeType,
171
+ sizeBytes: attachment.sizeBytes,
172
+ clipStart,
173
+ clipEnd,
174
+ clipDuration,
175
+ requestedRange: {
176
+ startTime,
177
+ endTime,
178
+ preRoll,
179
+ postRoll,
180
+ },
181
+ assetId,
182
+ }, null, 2),
183
+ isError: false,
184
+ };
185
+ } catch (err) {
186
+ return {
187
+ content: `Clip generation failed: ${(err as Error).message}`,
188
+ isError: true,
189
+ };
190
+ } finally {
191
+ // Clean up temp file and directory
192
+ try { await unlink(clipPath); } catch { /* ignore */ }
193
+ try { await rmdir(clipDir); } catch { /* ignore */ }
194
+ }
195
+ }
@@ -0,0 +1,197 @@
1
+ import { basename, extname } from 'node:path';
2
+ import { access } from 'node:fs/promises';
3
+ import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
4
+ import {
5
+ registerMediaAsset,
6
+ getMediaAssetByHash,
7
+ createProcessingStage,
8
+ updateMediaAssetStatus,
9
+ computeFileHash,
10
+ type MediaType,
11
+ } from '../../../../memory/media-store.js';
12
+ import { enqueueMemoryJob } from '../../../../memory/jobs-store.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // MIME detection
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const MIME_MAP: Record<string, string> = {
19
+ // Video
20
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.avi': 'video/x-msvideo',
21
+ '.mkv': 'video/x-matroska', '.webm': 'video/webm', '.m4v': 'video/x-m4v',
22
+ '.mpeg': 'video/mpeg', '.mpg': 'video/mpeg', '.ts': 'video/mp2t',
23
+ '.flv': 'video/x-flv', '.wmv': 'video/x-ms-wmv',
24
+ // Audio
25
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/x-m4a',
26
+ '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.flac': 'audio/flac',
27
+ '.aiff': 'audio/aiff', '.wma': 'audio/x-ms-wma',
28
+ // Image
29
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
30
+ '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp',
31
+ '.tiff': 'image/tiff', '.svg': 'image/svg+xml', '.heic': 'image/heic',
32
+ };
33
+
34
+ function detectMimeType(filePath: string): string | null {
35
+ const ext = extname(filePath).toLowerCase();
36
+ return MIME_MAP[ext] ?? null;
37
+ }
38
+
39
+ function classifyMediaType(mimeType: string): MediaType | null {
40
+ if (mimeType.startsWith('video/')) return 'video';
41
+ if (mimeType.startsWith('audio/')) return 'audio';
42
+ if (mimeType.startsWith('image/')) return 'image';
43
+ return null;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // ffprobe duration extraction
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function spawnWithTimeout(
51
+ cmd: string[],
52
+ timeoutMs: number,
53
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
54
+ return new Promise((resolve, reject) => {
55
+ const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe' });
56
+ const timer = setTimeout(() => {
57
+ proc.kill();
58
+ reject(new Error(`Process timed out after ${timeoutMs}ms: ${cmd[0]}`));
59
+ }, timeoutMs);
60
+ proc.exited.then(async (exitCode) => {
61
+ clearTimeout(timer);
62
+ const stdout = await new Response(proc.stdout).text();
63
+ const stderr = await new Response(proc.stderr).text();
64
+ resolve({ exitCode, stdout, stderr });
65
+ });
66
+ });
67
+ }
68
+
69
+ async function extractDuration(filePath: string): Promise<number | null> {
70
+ try {
71
+ const result = await spawnWithTimeout([
72
+ 'ffprobe', '-v', 'error',
73
+ '-show_entries', 'format=duration',
74
+ '-of', 'csv=p=0',
75
+ filePath,
76
+ ], 15_000);
77
+ if (result.exitCode !== 0) return null;
78
+ const duration = parseFloat(result.stdout.trim());
79
+ return Number.isFinite(duration) ? duration : null;
80
+ } catch {
81
+ // ffprobe not available or timed out
82
+ return null;
83
+ }
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Main entry point
88
+ // ---------------------------------------------------------------------------
89
+
90
+ export async function run(
91
+ input: Record<string, unknown>,
92
+ context: ToolContext,
93
+ ): Promise<ToolExecutionResult> {
94
+ const filePath = input.file_path as string | undefined;
95
+ if (!filePath) {
96
+ return { content: 'file_path is required. Provide an absolute path to a local media file.', isError: true };
97
+ }
98
+
99
+ // Validate file exists
100
+ try {
101
+ await access(filePath);
102
+ } catch {
103
+ return { content: `File not found: ${filePath}`, isError: true };
104
+ }
105
+
106
+ // Detect MIME type
107
+ const mimeType = detectMimeType(filePath);
108
+ if (!mimeType) {
109
+ return {
110
+ content: `Unsupported file type: ${extname(filePath)}. Supported: video (mp4, mov, avi, mkv, webm, etc.), audio (mp3, wav, m4a, etc.), image (png, jpg, gif, webp, etc.).`,
111
+ isError: true,
112
+ };
113
+ }
114
+
115
+ const mediaType = classifyMediaType(mimeType);
116
+ if (!mediaType) {
117
+ return { content: `Could not classify media type for MIME: ${mimeType}`, isError: true };
118
+ }
119
+
120
+ // Compute content hash for dedup – uses the same Bun.hash (wyhash) as
121
+ // media-store.ts so that hashes are consistent across ingest and lookup.
122
+ context.onOutput?.('Computing content hash...\n');
123
+ const fileBytes = await Bun.file(filePath).arrayBuffer();
124
+ const fileHash = computeFileHash(new Uint8Array(fileBytes));
125
+
126
+ // Check for existing asset with same hash
127
+ const existingAsset = getMediaAssetByHash(fileHash);
128
+ if (existingAsset) {
129
+ return {
130
+ content: JSON.stringify({
131
+ message: 'Media asset already registered (duplicate detected by content hash)',
132
+ asset: existingAsset,
133
+ deduplicated: true,
134
+ }, null, 2),
135
+ isError: false,
136
+ };
137
+ }
138
+
139
+ // Extract duration for video/audio
140
+ let durationSeconds: number | null = null;
141
+ if (mediaType === 'video' || mediaType === 'audio') {
142
+ context.onOutput?.('Extracting duration via ffprobe...\n');
143
+ durationSeconds = await extractDuration(filePath);
144
+ }
145
+
146
+ // Determine title
147
+ const title = (input.title as string) || basename(filePath);
148
+
149
+ // Parse optional metadata
150
+ let metadata: Record<string, unknown> | undefined;
151
+ if (input.metadata && typeof input.metadata === 'object') {
152
+ metadata = input.metadata as Record<string, unknown>;
153
+ }
154
+
155
+ // Register the asset
156
+ const asset = registerMediaAsset({
157
+ title,
158
+ filePath,
159
+ mimeType,
160
+ durationSeconds,
161
+ fileHash,
162
+ mediaType,
163
+ metadata,
164
+ });
165
+
166
+ // Create an initial processing stage
167
+ createProcessingStage({
168
+ assetId: asset.id,
169
+ stage: 'ingest',
170
+ });
171
+
172
+ // Update status to processing
173
+ updateMediaAssetStatus(asset.id, 'processing');
174
+
175
+ // Enqueue a processing job via the existing jobs framework
176
+ enqueueMemoryJob('media_processing', {
177
+ mediaAssetId: asset.id,
178
+ stage: 'ingest',
179
+ filePath,
180
+ mimeType,
181
+ mediaType,
182
+ });
183
+
184
+ context.onOutput?.(`Registered media asset: ${asset.id}\n`);
185
+
186
+ return {
187
+ content: JSON.stringify({
188
+ message: 'Media asset registered and processing enqueued',
189
+ asset: {
190
+ ...asset,
191
+ status: 'processing',
192
+ },
193
+ deduplicated: false,
194
+ }, null, 2),
195
+ isError: false,
196
+ };
197
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Media diagnostics tool.
3
+ *
4
+ * Surfaces processing stats, per-stage timing, failure reasons,
5
+ * cost estimation, and feedback summary for a media asset.
6
+ * All metrics are generic media-processing infrastructure.
7
+ */
8
+
9
+ import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
10
+ import {
11
+ getMediaAssetById,
12
+ getProcessingStagesForAsset,
13
+ getKeyframesForAsset,
14
+ getVisionOutputsForAsset,
15
+ getTimelineForAsset,
16
+ getEventsForAsset,
17
+ type ProcessingStage,
18
+ } from '../../../../memory/media-store.js';
19
+ import { aggregateFeedback } from '../services/feedback-aggregation.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Cost estimation constants
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** Estimated cost per vision API call (one keyframe analysis). */
26
+ const ESTIMATED_COST_PER_FRAME_USD = 0.003;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ interface StageDiagnostic {
33
+ stage: string;
34
+ status: string;
35
+ progress: number;
36
+ durationMs: number | null;
37
+ lastError: string | null;
38
+ }
39
+
40
+ interface DiagnosticReport {
41
+ assetId: string;
42
+ assetTitle: string;
43
+ assetStatus: string;
44
+ mediaType: string;
45
+ durationSeconds: number | null;
46
+ processingStats: {
47
+ totalKeyframes: number;
48
+ totalVisionOutputs: number;
49
+ totalTimelineSegments: number;
50
+ totalEventsDetected: number;
51
+ };
52
+ stages: StageDiagnostic[];
53
+ costEstimate: {
54
+ keyframeCount: number;
55
+ estimatedCostPerFrame: number;
56
+ estimatedTotalCost: number;
57
+ currency: string;
58
+ };
59
+ feedbackSummary: {
60
+ totalFeedbackEntries: number;
61
+ statsByEventType: Array<{
62
+ eventType: string;
63
+ totalEvents: number;
64
+ correct: number;
65
+ incorrect: number;
66
+ boundaryEdit: number;
67
+ missed: number;
68
+ precision: number | null;
69
+ recall: number | null;
70
+ }>;
71
+ };
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Helpers
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function computeStageDuration(stage: ProcessingStage): number | null {
79
+ if (stage.startedAt == null) return null;
80
+ if (stage.completedAt != null) return stage.completedAt - stage.startedAt;
81
+ // Only use Date.now() as a fallback for currently running stages
82
+ if (stage.status === 'running') return Date.now() - stage.startedAt;
83
+ // For failed/pending stages without completedAt, duration is unknown
84
+ return null;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Main entry point
89
+ // ---------------------------------------------------------------------------
90
+
91
+ export async function run(
92
+ input: Record<string, unknown>,
93
+ _context: ToolContext,
94
+ ): Promise<ToolExecutionResult> {
95
+ const assetId = input.asset_id as string | undefined;
96
+ if (!assetId) {
97
+ return { content: 'asset_id is required.', isError: true };
98
+ }
99
+
100
+ const asset = getMediaAssetById(assetId);
101
+ if (!asset) {
102
+ return { content: `Media asset not found: ${assetId}`, isError: true };
103
+ }
104
+
105
+ // Gather processing stats
106
+ const keyframes = getKeyframesForAsset(assetId);
107
+ const visionOutputs = getVisionOutputsForAsset(assetId);
108
+ const timelineSegments = getTimelineForAsset(assetId);
109
+ const events = getEventsForAsset(assetId);
110
+
111
+ // Per-stage diagnostics
112
+ const stages = getProcessingStagesForAsset(assetId);
113
+ const stageDiagnostics: StageDiagnostic[] = stages.map((s) => ({
114
+ stage: s.stage,
115
+ status: s.status,
116
+ progress: s.progress,
117
+ durationMs: computeStageDuration(s),
118
+ lastError: s.lastError,
119
+ }));
120
+
121
+ // Cost estimation based on keyframe count
122
+ const keyframeCount = keyframes.length;
123
+ const estimatedTotalCost = keyframeCount * ESTIMATED_COST_PER_FRAME_USD;
124
+
125
+ // Feedback summary
126
+ const feedbackResult = aggregateFeedback(assetId);
127
+
128
+ const report: DiagnosticReport = {
129
+ assetId: asset.id,
130
+ assetTitle: asset.title,
131
+ assetStatus: asset.status,
132
+ mediaType: asset.mediaType,
133
+ durationSeconds: asset.durationSeconds,
134
+ processingStats: {
135
+ totalKeyframes: keyframeCount,
136
+ totalVisionOutputs: visionOutputs.length,
137
+ totalTimelineSegments: timelineSegments.length,
138
+ totalEventsDetected: events.length,
139
+ },
140
+ stages: stageDiagnostics,
141
+ costEstimate: {
142
+ keyframeCount,
143
+ estimatedCostPerFrame: ESTIMATED_COST_PER_FRAME_USD,
144
+ estimatedTotalCost: Math.round(estimatedTotalCost * 1000) / 1000,
145
+ currency: 'USD',
146
+ },
147
+ feedbackSummary: {
148
+ totalFeedbackEntries: feedbackResult.totalFeedbackEntries,
149
+ statsByEventType: feedbackResult.statsByEventType.map((s) => ({
150
+ eventType: s.eventType,
151
+ totalEvents: s.totalEvents,
152
+ correct: s.correct,
153
+ incorrect: s.incorrect,
154
+ boundaryEdit: s.boundaryEdit,
155
+ missed: s.missed,
156
+ precision: s.precision,
157
+ recall: s.recall,
158
+ })),
159
+ },
160
+ };
161
+
162
+ return {
163
+ content: JSON.stringify(report, null, 2),
164
+ isError: false,
165
+ };
166
+ }
@@ -0,0 +1,75 @@
1
+ import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
2
+ import {
3
+ getMediaAssetById,
4
+ getMediaAssetByFilePath,
5
+ getMediaAssetsByStatus,
6
+ getProcessingStagesForAsset,
7
+ type MediaAssetStatus,
8
+ } from '../../../../memory/media-store.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Main entry point
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export async function run(
15
+ input: Record<string, unknown>,
16
+ _context: ToolContext,
17
+ ): Promise<ToolExecutionResult> {
18
+ const assetId = input.asset_id as string | undefined;
19
+ const filePath = input.file_path as string | undefined;
20
+ const statusFilter = input.status_filter as MediaAssetStatus | undefined;
21
+
22
+ // Query by asset ID
23
+ if (assetId) {
24
+ const asset = getMediaAssetById(assetId);
25
+ if (!asset) {
26
+ return { content: `Media asset not found: ${assetId}`, isError: true };
27
+ }
28
+ const stages = getProcessingStagesForAsset(asset.id);
29
+ return {
30
+ content: JSON.stringify({ asset, stages }, null, 2),
31
+ isError: false,
32
+ };
33
+ }
34
+
35
+ // Query by file path
36
+ if (filePath) {
37
+ const asset = getMediaAssetByFilePath(filePath);
38
+ if (!asset) {
39
+ return { content: `No media asset found for path: ${filePath}`, isError: true };
40
+ }
41
+ const stages = getProcessingStagesForAsset(asset.id);
42
+ return {
43
+ content: JSON.stringify({ asset, stages }, null, 2),
44
+ isError: false,
45
+ };
46
+ }
47
+
48
+ // Query by status filter
49
+ if (statusFilter) {
50
+ const validStatuses: MediaAssetStatus[] = ['registered', 'processing', 'indexed', 'failed'];
51
+ if (!validStatuses.includes(statusFilter)) {
52
+ return {
53
+ content: `Invalid status filter: ${statusFilter}. Valid values: ${validStatuses.join(', ')}`,
54
+ isError: true,
55
+ };
56
+ }
57
+ const assets = getMediaAssetsByStatus(statusFilter);
58
+ const results = assets.map((asset) => ({
59
+ asset,
60
+ stages: getProcessingStagesForAsset(asset.id),
61
+ }));
62
+ return {
63
+ content: JSON.stringify({
64
+ count: results.length,
65
+ assets: results,
66
+ }, null, 2),
67
+ isError: false,
68
+ };
69
+ }
70
+
71
+ return {
72
+ content: 'Provide at least one query parameter: asset_id, file_path, or status_filter.',
73
+ isError: true,
74
+ };
75
+ }