@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.
- package/Dockerfile +2 -0
- package/README.md +45 -18
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +13 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +391 -1
- package/src/__tests__/call-routes-http.test.ts +27 -2
- package/src/__tests__/channel-approval-routes.test.ts +397 -135
- package/src/__tests__/channel-approvals.test.ts +99 -3
- package/src/__tests__/channel-delivery-store.test.ts +30 -4
- package/src/__tests__/channel-guardian.test.ts +261 -22
- package/src/__tests__/channel-readiness-service.test.ts +257 -0
- package/src/__tests__/config-schema.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +636 -0
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
- package/src/__tests__/handlers-twilio-config.test.ts +480 -0
- package/src/__tests__/ipc-snapshot.test.ts +63 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
- package/src/__tests__/run-orchestrator.test.ts +22 -0
- package/src/__tests__/secret-scanner.test.ts +223 -0
- package/src/__tests__/session-runtime-assembly.test.ts +85 -1
- package/src/__tests__/shell-parser-property.test.ts +357 -2
- package/src/__tests__/sms-messaging-provider.test.ts +125 -0
- package/src/__tests__/system-prompt.test.ts +25 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
- package/src/__tests__/twilio-routes.test.ts +39 -3
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/user-reference.test.ts +68 -0
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/calls/call-domain.ts +8 -5
- package/src/calls/call-orchestrator.ts +85 -22
- package/src/calls/twilio-config.ts +17 -11
- package/src/calls/twilio-rest.ts +276 -0
- package/src/calls/twilio-routes.ts +39 -1
- package/src/cli/map.ts +6 -0
- package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
- package/src/commands/cc-command-registry.ts +14 -1
- package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
- package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
- package/src/config/bundled-skills/messaging/SKILL.md +24 -5
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/defaults.ts +2 -1
- package/src/config/schema.ts +9 -3
- package/src/config/skills.ts +5 -32
- package/src/config/system-prompt.ts +40 -0
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/user-reference.ts +29 -0
- package/src/config/vellum-skills/catalog.json +58 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
- package/src/daemon/auth-manager.ts +103 -0
- package/src/daemon/computer-use-session.ts +8 -1
- package/src/daemon/config-watcher.ts +253 -0
- package/src/daemon/handlers/config.ts +819 -22
- package/src/daemon/handlers/dictation.ts +182 -0
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +2 -0
- package/src/daemon/handlers/sessions.ts +2 -0
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +6 -7
- package/src/daemon/handlers/work-items.ts +15 -7
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +114 -4
- package/src/daemon/ipc-handler.ts +87 -0
- package/src/daemon/lifecycle.ts +18 -4
- package/src/daemon/ride-shotgun-handler.ts +11 -1
- package/src/daemon/server.ts +111 -504
- package/src/daemon/session-agent-loop.ts +10 -15
- package/src/daemon/session-runtime-assembly.ts +115 -44
- package/src/daemon/session-tool-setup.ts +2 -0
- package/src/daemon/session.ts +19 -2
- package/src/inbound/public-ingress-urls.ts +3 -3
- package/src/memory/channel-guardian-store.ts +2 -1
- package/src/memory/db-connection.ts +28 -0
- package/src/memory/db-init.ts +1163 -0
- package/src/memory/db.ts +2 -2007
- package/src/memory/embedding-backend.ts +79 -11
- package/src/memory/indexer.ts +2 -0
- package/src/memory/job-handlers/media-processing.ts +100 -0
- package/src/memory/job-utils.ts +64 -4
- package/src/memory/jobs-store.ts +2 -1
- package/src/memory/jobs-worker.ts +11 -1
- package/src/memory/media-store.ts +759 -0
- package/src/memory/recall-cache.ts +107 -0
- package/src/memory/retriever.ts +36 -2
- package/src/memory/schema-migration.ts +984 -0
- package/src/memory/schema.ts +99 -0
- package/src/memory/search/entity.ts +208 -25
- package/src/memory/search/ranking.ts +6 -1
- package/src/memory/search/types.ts +26 -0
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +204 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/permissions/checker.ts +16 -2
- package/src/permissions/prompter.ts +14 -3
- package/src/permissions/trust-store.ts +7 -0
- package/src/runtime/approval-message-composer.ts +143 -0
- package/src/runtime/channel-approvals.ts +29 -7
- package/src/runtime/channel-guardian-service.ts +44 -18
- package/src/runtime/channel-readiness-service.ts +292 -0
- package/src/runtime/channel-readiness-types.ts +29 -0
- package/src/runtime/gateway-client.ts +2 -1
- package/src/runtime/http-server.ts +65 -28
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-routes.ts +237 -103
- package/src/runtime/routes/run-routes.ts +7 -1
- package/src/runtime/run-orchestrator.ts +43 -3
- package/src/security/secret-scanner.ts +218 -0
- package/src/skills/frontmatter.ts +63 -0
- package/src/skills/slash-commands.ts +23 -0
- package/src/skills/vellum-catalog-remote.ts +107 -0
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/browser/auto-navigate.ts +132 -24
- package/src/tools/browser/browser-manager.ts +67 -61
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/claude-code/claude-code.ts +55 -3
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/executor.ts +10 -2
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/skills/vellum-catalog.ts +61 -156
- package/src/tools/terminal/parser.ts +21 -5
- package/src/tools/types.ts +2 -0
- package/src/twitter/router.ts +1 -1
- package/src/util/platform.ts +43 -1
- 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
|
+
}
|