Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions pkg/create/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ const (
TemplateBrowserUse = "browser-use"
TemplateStagehand = "stagehand"
TemplateOpenAGIComputerUse = "openagi-computer-use"
TemplateClaudeAgentSDK = "claude-agent-sdk"
TemplateYutoriComputerUse = "yutori"
TemplateClaudeAgentSDK = "claude-agent-sdk"
TemplateYutoriComputerUse = "yutori"
TemplateCloudglueSessionRecap = "cloudglue-session-recap"
)

type TemplateInfo struct {
Expand Down Expand Up @@ -90,6 +91,11 @@ var Templates = map[string]TemplateInfo{
Description: "Implements a Yutori n1 computer use agent",
Languages: []string{LanguageTypeScript, LanguagePython},
},
TemplateCloudglueSessionRecap: {
Name: "Cloudglue Session Recap",
Description: "Analyze browser session recordings with Cloudglue video AI",
Languages: []string{LanguageTypeScript},
},
}

// GetSupportedTemplatesForLanguage returns a list of all supported template names for a given language
Expand Down Expand Up @@ -213,6 +219,11 @@ var Commands = map[string]map[string]DeployConfig{
NeedsEnvFile: true,
InvokeCommand: `kernel invoke ts-yutori-cua cua-task --payload '{"query": "Navigate to https://example.com and describe the page"}'`,
},
TemplateCloudglueSessionRecap: {
EntryPoint: "index.ts",
NeedsEnvFile: true,
InvokeCommand: `kernel invoke ts-cloudglue-session-recap session-recap --payload '{"recording_url": "https://your-recording-url.com/replay.mp4"}'`,
},
},
LanguagePython: {
TemplateSampleApp: {
Expand Down
2 changes: 2 additions & 0 deletions pkg/templates/typescript/cloudglue-session-recap/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Get your API key at https://app.cloudglue.dev
CLOUDGLUE_API_KEY=your_cloudglue_api_key_here
88 changes: 88 additions & 0 deletions pkg/templates/typescript/cloudglue-session-recap/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Cloudglue Session Recap

Analyze browser session recordings with [Cloudglue](https://cloudglue.dev) to get a detailed scene-by-scene recap — thumbnails, timestamps, user actions, and screen descriptions.

## What it does

The **`session-recap`** action takes any video URL (a Kernel session recording, screen capture, or video file) and produces:

- A generated title and summary
- A thumbnail grid overview (4 columns)
- A detailed scene breakdown with timestamps, descriptions, user actions, URLs, and screen state
- A complete markdown document combining all of the above

Uses Cloudglue describe (for visual timeline + thumbnails) and segment-level extract (for structured user actions) in parallel.

## Input

```json
{
"recording_url": "https://example.com/recordings/replay.mp4",
"title": "Login Flow Test",
"max_seconds": 8
}
```

Only `recording_url` is required. `title` defaults to "Session Recap of \<url\>" and `max_seconds` defaults to 8.

## Output

```json
{
"title": "Login Flow Test",
"recording_url": "https://example.com/recordings/replay.mp4",
"summary": "A user navigates to the login page, enters credentials, and is redirected to the dashboard.",
"duration_seconds": 22.5,
"scene_count": 4,
"scenes": [
{
"timestamp": "00:00",
"thumbnail_url": "https://...",
"description": "Login page loaded with email and password input fields.",
"user_action": "Navigated to the login page",
"screen_description": "Login form with email field, password field, and Sign In button."
},
{
"timestamp": "00:06",
"thumbnail_url": "https://...",
"description": "User enters email and password into the login form.",
"user_action": "Typed credentials into the email and password fields",
"screen_description": "Login form with filled-in fields and cursor in the password input."
}
],
"markdown": "# Login Flow Test\n\n- **Recording:** ..."
}
```

## Setup

Create a `.env` file:

```
CLOUDGLUE_API_KEY=your-cloudglue-api-key
```

Get your API key at [app.cloudglue.dev](https://app.cloudglue.dev).

## Deploy

```bash
kernel login
kernel deploy index.ts --env-file .env
```

## Invoke

```bash
# Basic — auto-generates title from URL
kernel invoke ts-cloudglue-session-recap session-recap \
--payload '{"recording_url": "https://your-recording-url.com/replay.mp4"}'

# With custom title
kernel invoke ts-cloudglue-session-recap session-recap \
--payload '{"recording_url": "https://...", "title": "Login Flow Test"}'

# With longer scene windows (default is 8s, max 60s)
kernel invoke ts-cloudglue-session-recap session-recap \
--payload '{"recording_url": "https://...", "max_seconds": 15}'
```
39 changes: 39 additions & 0 deletions pkg/templates/typescript/cloudglue-session-recap/_gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Dependencies
node_modules/
package-lock.json

# TypeScript
*.tsbuildinfo
dist/
build/

# Environment
.env
.env.local
.env.*.local

# IDE
.vscode/
.idea/
*.swp
*.swo

# OS
.DS_Store
Thumbs.db

# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Testing
coverage/
.nyc_output/

# Misc
.cache/
.temp/
.tmp/
151 changes: 151 additions & 0 deletions pkg/templates/typescript/cloudglue-session-recap/cloudglue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { Cloudglue } from "@cloudglue/cloudglue-js";
import type { SegmentationConfig } from "@cloudglue/cloudglue-js";

const CLOUDGLUE_API_KEY = process.env.CLOUDGLUE_API_KEY;

if (!CLOUDGLUE_API_KEY) {
throw new Error("CLOUDGLUE_API_KEY is not set");
}

const client = new Cloudglue({ apiKey: CLOUDGLUE_API_KEY });

export interface SegmentationOptions {
maxSeconds?: number;
}

/** Build segmentation config with configurable max_seconds. */
function buildSegmentation(opts?: SegmentationOptions): SegmentationConfig {
return {
strategy: "shot-detector",
shot_detector_config: {
detector: "adaptive",
min_seconds: 1,
max_seconds: opts?.maxSeconds ?? 8,
fill_gaps: true,
},
};
}

function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

/**
* Retry a function up to maxRetries times with backoff.
*/
async function withRetry<T>(
fn: () => Promise<T>,
label: string,
maxRetries = 2,
backoffMs = 20_000
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err: unknown) {
const msg =
err instanceof Error ? err.message : String(err);
console.error(
`${label}: attempt ${attempt}/${maxRetries} failed: ${msg}`
);
if (attempt < maxRetries) {
console.log(`${label}: waiting ${backoffMs / 1000}s before retry...`);
await sleep(backoffMs);
console.log(`${label}: retrying (attempt ${attempt + 1})...`);
} else {
console.error(`${label}: all ${maxRetries} attempts exhausted.`);
throw err;
}
}
}
// Unreachable, but TypeScript needs it
throw new Error(`${label} failed`);
}

/**
* Poll a describe job until complete, fetching full data with thumbnails.
*/
async function pollDescribe(
jobId: string,
intervalMs = 5000
): Promise<Record<string, unknown>> {
while (true) {
const job = await client.describe.getDescribe(jobId, {
include_thumbnails: true,
include_shots: true,
});
if (job.status === "completed") return job as Record<string, unknown>;
if (job.status === "failed" || job.status === "not_applicable") {
throw new Error(`Describe job ${jobId} failed: ${JSON.stringify(job)}`);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}

/**
* Poll an extract job until complete, fetching thumbnails.
*/
async function pollExtract(
jobId: string,
intervalMs = 5000
): Promise<Record<string, unknown>> {
while (true) {
const job = await client.extract.getExtract(jobId, {
include_thumbnails: true,
include_shots: true,
});
if (job.status === "completed") return job as Record<string, unknown>;
if (job.status === "failed" || job.status === "not_applicable") {
throw new Error(`Extract job ${jobId} failed: ${JSON.stringify(job)}`);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}

/**
* Describe a video recording via Cloudglue.
* Retries up to 2 times with 20s backoff if the video isn't ready yet.
*/
export async function describeRecording(
url: string,
opts?: SegmentationOptions
): Promise<Record<string, unknown>> {
return withRetry(async () => {
const job = await client.describe.createDescribe(url, {
enable_visual_scene_description: true,
enable_scene_text: true,
enable_speech: true,
enable_summary: true,
segmentation_config: buildSegmentation(opts),
include_shots: true,
});

console.log(`Describe job created: ${job.job_id}`);
return await pollDescribe(job.job_id);
}, "Describe");
}

/**
* Extract structured data from a video at the segment level.
* Retries up to 2 times with 20s backoff if the video isn't ready yet.
*/
export async function extractSegmentLevel(
url: string,
schema: Record<string, unknown>,
prompt: string,
opts?: SegmentationOptions
): Promise<Record<string, unknown>> {
return withRetry(async () => {
const job = await client.extract.createExtract(url, {
url,
schema,
prompt,
enable_segment_level_entities: true,
segmentation_config: buildSegmentation(opts),
include_shots: true,
});

console.log(`Extract job created: ${job.job_id} (segment-level)`);
return await pollExtract(job.job_id);
}, "Extract");
}
Loading