@tday-com/mcp 0.0.0-stage → 0.2.0
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/LICENSE +36 -0
- package/README.md +105 -2
- package/dist/api-client.d.ts +23 -0
- package/dist/api-client.js +187 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +78 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +66 -0
- package/dist/demo-import.d.ts +6 -0
- package/dist/demo-import.js +101 -0
- package/dist/download-safety.d.ts +7 -0
- package/dist/download-safety.js +76 -0
- package/dist/installer-agents.d.ts +17 -0
- package/dist/installer-agents.js +283 -0
- package/dist/installer-callback.d.ts +13 -0
- package/dist/installer-callback.js +91 -0
- package/dist/installer-slash-command.d.ts +12 -0
- package/dist/installer-slash-command.js +262 -0
- package/dist/installer.d.ts +2 -0
- package/dist/installer.js +127 -0
- package/dist/observability.d.ts +1 -0
- package/dist/observability.js +77 -0
- package/dist/package-info.d.ts +11 -0
- package/dist/package-info.js +17 -0
- package/dist/result-format.d.ts +4 -0
- package/dist/result-format.js +154 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +37 -0
- package/dist/tool-schemas.d.ts +460 -0
- package/dist/tool-schemas.js +189 -0
- package/dist/tools-account.d.ts +6 -0
- package/dist/tools-account.js +34 -0
- package/dist/tools-demo.d.ts +11 -0
- package/dist/tools-demo.js +57 -0
- package/dist/tools-knowledge.d.ts +4 -0
- package/dist/tools-knowledge.js +111 -0
- package/dist/tools-launch.d.ts +4 -0
- package/dist/tools-launch.js +365 -0
- package/dist/tools.d.ts +15 -0
- package/dist/tools.js +296 -0
- package/package.json +58 -4
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type Tool } from "./tool-schemas.js";
|
|
3
|
+
export declare const accountTools: Tool[];
|
|
4
|
+
/** The preset sizes `tday_generate_design` accepts (web/src/lib/mcp/v1-image-formats.ts). */
|
|
5
|
+
export declare const IMAGE_FORMATS: readonly ["square", "ig_post", "story", "fb_post", "linkedin_post", "desktop", "a4_page"];
|
|
6
|
+
export declare const imageFormatSchema: z.ZodOptional<z.ZodEnum<["square", "ig_post", "story", "fb_post", "linkedin_post", "desktop", "a4_page"]>>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { api, query } from "./api-client.js";
|
|
3
|
+
import { argsObject, outputSchemas, readOnly, safeIdSchema } from "./tool-schemas.js";
|
|
4
|
+
// What things cost and what an organisation has spent. `tday_whoami` (tools.ts) is the account
|
|
5
|
+
// itself; these two sit beside it. The hosted connector registers the same three
|
|
6
|
+
// (web/src/lib/mcp/remote-account-tools.ts), so keep the descriptions in step.
|
|
7
|
+
export const accountTools = [
|
|
8
|
+
{
|
|
9
|
+
name: "tday_get_credit_prices",
|
|
10
|
+
title: "Credit prices",
|
|
11
|
+
description: "What each thing costs in credits at today's price list: an image design, an edit, design videos, launch videos (every length, with or without music and voiceover, already added up), a launch video revision, scans, and each plan's monthly credits. Use it to tell the person what something will cost BEFORE starting it, next to the balance from tday_whoami.",
|
|
12
|
+
inputSchema: argsObject({}),
|
|
13
|
+
outputSchema: outputSchemas.creditPrices,
|
|
14
|
+
annotations: readOnly,
|
|
15
|
+
handler: (_args, config) => api(config, "/api/v1/credits/prices"),
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "tday_get_usage",
|
|
19
|
+
title: "Credit usage",
|
|
20
|
+
description: "One organisation's credit balance and what it has spent this grant period, by action, by brand and by person, plus a link to its usage page. Pass organizationId from tday_whoami; omitted, the organisation the person owns.",
|
|
21
|
+
inputSchema: argsObject({
|
|
22
|
+
organizationId: safeIdSchema.optional().describe("Organisation ID from tday_whoami"),
|
|
23
|
+
}),
|
|
24
|
+
outputSchema: outputSchemas.usage,
|
|
25
|
+
annotations: readOnly,
|
|
26
|
+
handler: (args, config) => api(config, `/api/v1/usage${query({ organizationId: typeof args.organizationId === "string" ? args.organizationId : undefined })}`),
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
/** The preset sizes `tday_generate_design` accepts (web/src/lib/mcp/v1-image-formats.ts). */
|
|
30
|
+
export const IMAGE_FORMATS = ["square", "ig_post", "story", "fb_post", "linkedin_post", "desktop", "a4_page"];
|
|
31
|
+
export const imageFormatSchema = z
|
|
32
|
+
.enum(IMAGE_FORMATS)
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Preset size: square 1080x1080, ig_post 1080x1350 (4:5 portrait), story 1080x1920 (9:16), fb_post 1200x630, linkedin_post 1200x627, desktop 1920x1080, a4_page. Portrait, square and story are on the paid plans where the plan gates sizes (tday_whoami output.allSizes); asking for one that is not included is refused with an upgrade link.");
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Tool } from "./tool-schemas.js";
|
|
2
|
+
import type { TdayConfig } from "./config.js";
|
|
3
|
+
export declare const demoTools: Tool[];
|
|
4
|
+
/**
|
|
5
|
+
* Whether the linked account may import demos (GET /api/studio/demo-import: the admin-only
|
|
6
|
+
* mcp_demo_import flag, which needs walkthrough_demos). Decides whether the demo tools are
|
|
7
|
+
* listed at all, so a customer's agent is never told they exist. Fails closed: no token, an
|
|
8
|
+
* older tday without the route, a slow answer or any error hides them. The import routes
|
|
9
|
+
* still recheck on every call.
|
|
10
|
+
*/
|
|
11
|
+
export declare function demoImportAvailable(config: TdayConfig): Promise<boolean>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { api } from "./api-client.js";
|
|
3
|
+
import { argsObject, safeIdSchema, stripPresentationArgs, writeRemote } from "./tool-schemas.js";
|
|
4
|
+
import { importDemoBundle } from "./demo-import.js";
|
|
5
|
+
const metadata = {
|
|
6
|
+
brandId: safeIdSchema,
|
|
7
|
+
title: z.string().trim().min(1).max(200),
|
|
8
|
+
manifest: z.record(z.unknown()).describe("The portable editable/manifest.json produced by the repository demo recorder, not a filesystem path."),
|
|
9
|
+
assets: z.array(z.object({ name: z.string(), contentType: z.enum(["video/mp4", "audio/mpeg", "image/png"]),
|
|
10
|
+
sizeBytes: z.number().int().positive().max(104857600), sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
11
|
+
}).strict()).min(3).max(200),
|
|
12
|
+
};
|
|
13
|
+
export const demoTools = [
|
|
14
|
+
{
|
|
15
|
+
name: "tday_import_demo", title: "Import local editable demo",
|
|
16
|
+
description: "Import an already-recorded local branch demo into the linked tday account and return its Studio edit link. Reads only allowlisted media beside editable/manifest.json within the working directory. Uploads directly to storage, preserves narration, source footage and timeline, and never overwrites editor changes on retry. Requires the admin-only mcp_demo_import flag and an owned brand. First record the local feature with the repository demo skill and ElevenLabs; this tool does not record or synthesize speech.",
|
|
17
|
+
inputSchema: argsObject({ brandId: safeIdSchema, title: z.string().trim().min(1).max(200), manifestPath: z.string().min(1) }),
|
|
18
|
+
outputSchema: z.object({ projectId: z.string(), editorUrl: z.string(), reused: z.boolean() }),
|
|
19
|
+
annotations: { ...writeRemote, idempotentHint: true },
|
|
20
|
+
handler: (args, config) => importDemoBundle(config, String(args.manifestPath), String(args.brandId), String(args.title)),
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "tday_prepare_demo_import", title: "Prepare editable demo upload",
|
|
24
|
+
description: "Prepare direct uploads for a portable narrated demo bundle. Admin-only feature flag, active admin and owned brand required. Supply manifest JSON and each allowlisted asset's byte length and SHA256. PUT each local file to its returned uploadUrl with the returned headers, without the tday bearer token. Then call tday_complete_demo_import with the same metadata and ticket. A reused result already contains the edit link. Does not read local paths or record a demo.",
|
|
25
|
+
inputSchema: argsObject(metadata), outputSchema: z.object({}).passthrough(), annotations: writeRemote,
|
|
26
|
+
handler: (args, config) => api(config, "/api/studio/demo-import/prepare", { method: "POST", body: JSON.stringify(stripPresentationArgs(args)) }),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "tday_complete_demo_import", title: "Complete editable demo import",
|
|
30
|
+
description: "Verify all uploaded demo assets and create an editable Studio project, returning editorUrl. Pass the exact metadata from prepare and its unexpired ticket. Rechecks the admin-only feature flag and ownership. Retries reuse the project without overwriting human edits. Does not publish to social networks or enqueue a generation.",
|
|
31
|
+
inputSchema: argsObject({ ...metadata, ticket: z.string().min(1).max(4096) }),
|
|
32
|
+
outputSchema: z.object({ projectId: z.string(), editorUrl: z.string(), reused: z.boolean() }), annotations: { ...writeRemote, idempotentHint: true },
|
|
33
|
+
handler: (args, config) => api(config, "/api/studio/demo-import/complete", { method: "POST", body: JSON.stringify(stripPresentationArgs(args)) }),
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
/** Longest the server waits on start for the availability answer before hiding the tools. */
|
|
37
|
+
const AVAILABILITY_TIMEOUT_MS = 5_000;
|
|
38
|
+
/**
|
|
39
|
+
* Whether the linked account may import demos (GET /api/studio/demo-import: the admin-only
|
|
40
|
+
* mcp_demo_import flag, which needs walkthrough_demos). Decides whether the demo tools are
|
|
41
|
+
* listed at all, so a customer's agent is never told they exist. Fails closed: no token, an
|
|
42
|
+
* older tday without the route, a slow answer or any error hides them. The import routes
|
|
43
|
+
* still recheck on every call.
|
|
44
|
+
*/
|
|
45
|
+
export async function demoImportAvailable(config) {
|
|
46
|
+
if (!config.token)
|
|
47
|
+
return false;
|
|
48
|
+
try {
|
|
49
|
+
const result = await api(config, "/api/studio/demo-import", {
|
|
50
|
+
signal: AbortSignal.timeout(AVAILABILITY_TIMEOUT_MS),
|
|
51
|
+
});
|
|
52
|
+
return result?.available === true;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Tool } from "./tool-schemas.js";
|
|
2
|
+
/** The frontmatter vocabulary. Closed, so `type` stays a badge somebody can read. */
|
|
3
|
+
export declare const KNOWLEDGE_ENTRY_TYPES: readonly ["invariant", "trap", "history", "decision"];
|
|
4
|
+
export declare const knowledgeTools: Tool[];
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { api, query } from "./api-client.js";
|
|
3
|
+
import { argsObject, outputSchemas, readOnly, stripPresentationArgs, writeRemote, } from "./tool-schemas.js";
|
|
4
|
+
// The knowledge-base tools: record a fact the moment it is learned, and check what is
|
|
5
|
+
// already recorded before recording a near-duplicate. Split from tools.ts per the
|
|
6
|
+
// 500-line ceiling, the same way tool-schemas.ts and api-client.ts were.
|
|
7
|
+
//
|
|
8
|
+
// Both go through /api/v1/knowledge-entries, the bearer-key endpoint next to
|
|
9
|
+
// /api/v1/generate. Note what is NOT in the input schemas: `source`. The server stamps
|
|
10
|
+
// every write as "mcp" from the route it arrived on, so a caller cannot label its own
|
|
11
|
+
// entry as reviewed-by-something-else — and argsObject is strict, so passing it is a
|
|
12
|
+
// validation error rather than a silently ignored field.
|
|
13
|
+
/**
|
|
14
|
+
* The byte ceiling /api/v1/knowledge-entries enforces before it parses (`MAX_BODY_BYTES`
|
|
15
|
+
* in web/src/app/api/v1/knowledge-entries/route.ts). Mirrored, not lowered.
|
|
16
|
+
*
|
|
17
|
+
* The field limits below are in CHARACTERS and the cap is in BYTES, so the two only agree
|
|
18
|
+
* for ASCII: every field at its maximum serialises to 30,274 bytes of ASCII — 46% of the
|
|
19
|
+
* cap, so the schema cannot overrun it on its own — but the same shape with a CJK body is
|
|
20
|
+
* 70,274, and one full of control characters (escaped to \uXXXX, six bytes each) is 120,011.
|
|
21
|
+
* Those pass validation and would come back as a bare 400 about a limit the caller was
|
|
22
|
+
* never told, so measure the serialised payload here and name the field to shrink.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
25
|
+
/** The frontmatter vocabulary. Closed, so `type` stays a badge somebody can read. */
|
|
26
|
+
export const KNOWLEDGE_ENTRY_TYPES = ["invariant", "trap", "history", "decision"];
|
|
27
|
+
const entryTypeSchema = z
|
|
28
|
+
.enum(KNOWLEDGE_ENTRY_TYPES)
|
|
29
|
+
.describe("invariant (a rule that must hold), trap (a way this bites you), history (why it ended up like this), or decision (a choice and its reasoning).");
|
|
30
|
+
export const knowledgeTools = [
|
|
31
|
+
{
|
|
32
|
+
name: "tday_search_knowledge_entries",
|
|
33
|
+
title: "Search knowledge entries",
|
|
34
|
+
description: "Search the tday codebase knowledge base — reviewed entries and unreviewed drafts together — by free text. Call this BEFORE tday_create_knowledge_entry: if a hit with \"draft\": true already states the fact, reuse that slug to update it instead of adding a near-duplicate. A hit with \"draft\": false is a reviewed, committed entry and is READ-ONLY here — recording its slug fails with 409; correct it in a pull request against its `path`, or record the new fact under a different slug. Bodies are truncated to 1200 characters.",
|
|
35
|
+
inputSchema: argsObject({
|
|
36
|
+
query: z
|
|
37
|
+
.string()
|
|
38
|
+
.trim()
|
|
39
|
+
.min(1)
|
|
40
|
+
.max(500)
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Free text, matched against slug, name, description, tracked paths, and body. Omit to list what exists."),
|
|
43
|
+
type: entryTypeSchema.optional().describe("Restrict results to one entry type."),
|
|
44
|
+
limit: z
|
|
45
|
+
.number()
|
|
46
|
+
.int()
|
|
47
|
+
.min(1)
|
|
48
|
+
.max(50)
|
|
49
|
+
.default(10)
|
|
50
|
+
.describe("Maximum entries to return. Defaults to 10."),
|
|
51
|
+
}),
|
|
52
|
+
outputSchema: outputSchemas.searchKnowledgeEntries,
|
|
53
|
+
annotations: readOnly,
|
|
54
|
+
handler: (args, config) => api(config, `/api/v1/knowledge-entries${query({
|
|
55
|
+
q: args.query,
|
|
56
|
+
type: args.type,
|
|
57
|
+
limit: Number(args.limit),
|
|
58
|
+
})}`),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "tday_create_knowledge_entry",
|
|
62
|
+
title: "Record knowledge entry",
|
|
63
|
+
description: "Record something learned about the tday codebase as a draft knowledge entry, visible in the knowledge graph immediately and marked as unreviewed. Use it the moment an investigation produces a durable fact, not at the end of a session. Search first with tday_search_knowledge_entries; recording an existing DRAFT's slug (a hit with \"draft\": true) UPDATES that entry rather than creating a second one. Only drafts can be updated: a reviewed, committed entry (\"draft\": false) is read-only through this tool and its slug is rejected with 409 — retrying it will not succeed, so either edit that file in a pull request or pick a different slug. Requires a tday staff account.",
|
|
64
|
+
inputSchema: argsObject({
|
|
65
|
+
name: z
|
|
66
|
+
.string()
|
|
67
|
+
.trim()
|
|
68
|
+
.min(1)
|
|
69
|
+
.max(120)
|
|
70
|
+
.describe("Short title for the fact, for example 'Money is stored in microdollars'."),
|
|
71
|
+
description: z
|
|
72
|
+
.string()
|
|
73
|
+
.trim()
|
|
74
|
+
.min(1)
|
|
75
|
+
.max(300)
|
|
76
|
+
.describe("One line, read when deciding whether this entry is relevant."),
|
|
77
|
+
type: entryTypeSchema,
|
|
78
|
+
body: z
|
|
79
|
+
.string()
|
|
80
|
+
.trim()
|
|
81
|
+
.min(1)
|
|
82
|
+
.max(20_000)
|
|
83
|
+
.describe("Markdown. State the fact, why it is that way, and what to do about it. [[other-entry-slug]] links become edges in the graph."),
|
|
84
|
+
tracks: z
|
|
85
|
+
.array(z.string().trim().min(1).max(300))
|
|
86
|
+
.max(32)
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("Repo-relative paths whose change would make this entry false, for example web/src/lib/pricing/constants.ts. Paths that cannot be normalised are dropped."),
|
|
89
|
+
slug: z
|
|
90
|
+
.string()
|
|
91
|
+
.trim()
|
|
92
|
+
.min(2)
|
|
93
|
+
.max(80)
|
|
94
|
+
.optional()
|
|
95
|
+
.describe("Kebab-case key, for example money-in-microdollars. Derived from name when omitted. Reuse an existing draft's slug to correct it."),
|
|
96
|
+
}),
|
|
97
|
+
outputSchema: outputSchemas.createKnowledgeEntry,
|
|
98
|
+
annotations: writeRemote,
|
|
99
|
+
handler: (args, config) => {
|
|
100
|
+
const payload = JSON.stringify(stripPresentationArgs(args));
|
|
101
|
+
const bytes = new TextEncoder().encode(payload).length;
|
|
102
|
+
if (bytes > MAX_REQUEST_BYTES) {
|
|
103
|
+
const body = typeof args.body === "string" ? args.body : "";
|
|
104
|
+
throw new Error(`This entry serialises to ${bytes} bytes and /api/v1/knowledge-entries accepts at most ${MAX_REQUEST_BYTES}. ` +
|
|
105
|
+
`The per-field limits count characters, not bytes, so non-ASCII text can pass them and still be too large. ` +
|
|
106
|
+
`Shorten body (${body.length} characters) and retry.`);
|
|
107
|
+
}
|
|
108
|
+
return api(config, "/api/v1/knowledge-entries", { method: "POST", body: payload });
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
];
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, rm } from "node:fs/promises";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { api } from "./api-client.js";
|
|
5
|
+
import { prepareOutputFilePath, validateImageUrl } from "./download-safety.js";
|
|
6
|
+
import { argsObject, readOnly, safeIdSchema, safePath, writeLocal, writeRemote, } from "./tool-schemas.js";
|
|
7
|
+
// The launch-video (launch film) tools. They drive the same routes tday's Launch page
|
|
8
|
+
// does, so tday's own pipeline writes the brief, storyboard, motion, score and narration:
|
|
9
|
+
// the caller supplies a subject in words and the creative axes, never HTML, a screenshot
|
|
10
|
+
// or a file. Split from tools.ts per the 500-line ceiling, like tools-knowledge.ts.
|
|
11
|
+
//
|
|
12
|
+
// The hosted connector has the same four tools (web/src/lib/mcp/remote-launch-tools.ts).
|
|
13
|
+
// The two cannot share code, so keep them in step. The option vocabularies below mirror
|
|
14
|
+
// web/src/lib/showcase/video-*.ts; an unknown voice id falls back to the default voice there.
|
|
15
|
+
/** The features route's own title cap (web/src/app/api/showcase/features/route.ts). */
|
|
16
|
+
const MAX_TITLE_CHARS = 160;
|
|
17
|
+
const MAX_DESCRIPTION_CHARS = 4000;
|
|
18
|
+
/** Creative notes ride inside every agent's prompt, so the route caps them (video-direction.ts). */
|
|
19
|
+
const MAX_DIRECTION_CHARS = 600;
|
|
20
|
+
/** A long (50s) film at 1080p is tens of megabytes; this is a generous ceiling, not a target. */
|
|
21
|
+
export const MAX_VIDEO_BYTES = 250 * 1024 * 1024;
|
|
22
|
+
const MUSIC_MOODS = ["Cinematic", "Upbeat", "Minimal", "Warm", "Driving"];
|
|
23
|
+
const VOICES = "Reece (OvzQwLvkMu8kuS6YKiR6), warm conversational narrator; Ivy (MClEFoImJXBTgLwdLI5n), bright friendly presenter; " +
|
|
24
|
+
"Janet (21m00Tcm4TlvDq8ikWAM), warm expressive explainer; Sarah (EXAVITQu4vr4xnSDxMaL), upbeat lively presenter; " +
|
|
25
|
+
"Craig (TxGEqnHWrfWFTfGW9XjX), deep polished broadcast voice";
|
|
26
|
+
const POLL_GUIDANCE = "Poll tday_poll_launch_video with this videoId every 20-30 seconds until it is ready.";
|
|
27
|
+
const VOICEOVER_NOT_INCLUDED = "Voiceover is not included on this organisation's plan, so the film is silent and no voice was charged.";
|
|
28
|
+
const videoIdSchema = safeIdSchema.describe("videoId returned by tday_create_launch_video");
|
|
29
|
+
const outputSchemas = {
|
|
30
|
+
create: z
|
|
31
|
+
.object({ videoId: z.string(), status: z.string(), narration: z.string(), reviewUrl: z.string() })
|
|
32
|
+
.passthrough(),
|
|
33
|
+
poll: z
|
|
34
|
+
.object({ videoId: z.string(), status: z.string(), state: z.string(), terminal: z.boolean(), reviewUrl: z.string() })
|
|
35
|
+
.passthrough(),
|
|
36
|
+
mp4: z
|
|
37
|
+
.object({
|
|
38
|
+
videoId: z.string(),
|
|
39
|
+
versionId: z.string(),
|
|
40
|
+
fps: z.number(),
|
|
41
|
+
state: z.enum(["ready", "rendering", "failed"]),
|
|
42
|
+
mp4Url: z.string().optional(),
|
|
43
|
+
progress: z.number().nullable().optional(),
|
|
44
|
+
saved: z.boolean().optional(),
|
|
45
|
+
path: z.string().optional(),
|
|
46
|
+
size: z.number().optional(),
|
|
47
|
+
})
|
|
48
|
+
.passthrough(),
|
|
49
|
+
revise: z.object({ videoId: z.string(), status: z.string(), reviewUrl: z.string() }).passthrough(),
|
|
50
|
+
};
|
|
51
|
+
/** How long a launch film's first export usually takes; later asks return the stored file. */
|
|
52
|
+
const FIRST_EXPORT = "The first request for a cut at a frame rate renders its file, which usually takes a minute or two";
|
|
53
|
+
export const launchTools = [
|
|
54
|
+
{
|
|
55
|
+
name: "tday_create_launch_video",
|
|
56
|
+
title: "Create launch video",
|
|
57
|
+
description: "Make a launch video: a short animated film announcing a feature or product, in the brand's own look. tday's pipeline writes the brief, storyboard, motion, music and narration from the title and description; describe the subject well and pick the options. " +
|
|
58
|
+
"It spends credits from the brand's organisation: a standard (20s), scored, silent film is 40 credits, and a voiceover or another length changes that (tday_get_credit_prices has the full list). " +
|
|
59
|
+
"A 402 means the balance cannot cover it: nothing was started, so do not retry. Returns a videoId and a reviewUrl at once; the film takes about 2-5 minutes, so poll tday_poll_launch_video.",
|
|
60
|
+
inputSchema: argsObject({
|
|
61
|
+
brandId: safeIdSchema.describe("Brand ID from tday_list_brands. The film uses this brand's colours, fonts and logo."),
|
|
62
|
+
title: z.string().trim().min(1).max(MAX_TITLE_CHARS).describe("The feature or product the film launches, as a short name."),
|
|
63
|
+
description: z
|
|
64
|
+
.string()
|
|
65
|
+
.trim()
|
|
66
|
+
.min(1)
|
|
67
|
+
.max(MAX_DESCRIPTION_CHARS)
|
|
68
|
+
.describe("What the feature or product does and who it is for. The film's brief is written from this, so be specific."),
|
|
69
|
+
length: z
|
|
70
|
+
.enum(["short", "standard", "long"])
|
|
71
|
+
.default("standard")
|
|
72
|
+
.describe("short (about 10s), standard (about 20s, the default) or long (about 50s)."),
|
|
73
|
+
narration: z
|
|
74
|
+
.enum(["silent", "voiceover"])
|
|
75
|
+
.default("silent")
|
|
76
|
+
.describe("silent (music and motion carry it, the default) or voiceover (narrated, with captions; costs more)."),
|
|
77
|
+
music: z.enum(["score", "none"]).default("score").describe("score (a bed written to the film's length, the default) or none."),
|
|
78
|
+
musicMood: z.enum(MUSIC_MOODS).optional().describe("A mood for the score. Omit to let the planner choose."),
|
|
79
|
+
voiceId: safeIdSchema.optional().describe(`The narrator of a voiceover film. Omit for the default. One of: ${VOICES}.`),
|
|
80
|
+
direction: z
|
|
81
|
+
.string()
|
|
82
|
+
.trim()
|
|
83
|
+
.min(1)
|
|
84
|
+
.max(MAX_DIRECTION_CHARS)
|
|
85
|
+
.optional()
|
|
86
|
+
.describe("Optional creative notes in plain words, for example 'lead on the scrubbing, end on the call to action'."),
|
|
87
|
+
}),
|
|
88
|
+
outputSchema: outputSchemas.create,
|
|
89
|
+
annotations: writeRemote,
|
|
90
|
+
handler: async (args, config) => {
|
|
91
|
+
const saved = await api(config, "/api/showcase/features", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
body: JSON.stringify({ brandId: args.brandId, title: args.title, description: args.description, typeId: null }),
|
|
94
|
+
});
|
|
95
|
+
const featureId = saved?.feature?.id;
|
|
96
|
+
if (typeof featureId !== "string")
|
|
97
|
+
throw new Error("tday could not start that film. Try again in a moment.");
|
|
98
|
+
const created = await api(config, safePath("/api/showcase/features/:id/create-launch-video", { id: featureId }), { method: "POST", body: JSON.stringify(launchAxes(args)) }).catch((error) => {
|
|
99
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} ${subjectSaved(args.title)}`);
|
|
100
|
+
});
|
|
101
|
+
const videoId = created?.runFeatureId;
|
|
102
|
+
if (typeof videoId !== "string")
|
|
103
|
+
throw new Error("tday could not start that film. Try again in a moment.");
|
|
104
|
+
const narration = created?.narration === "voiceover" || created?.narration === "silent" ? created.narration : args.narration;
|
|
105
|
+
return {
|
|
106
|
+
videoId,
|
|
107
|
+
status: "queued",
|
|
108
|
+
length: args.length,
|
|
109
|
+
narration,
|
|
110
|
+
music: args.music,
|
|
111
|
+
reviewUrl: reviewUrl(config, videoId),
|
|
112
|
+
...(args.narration === "voiceover" && narration === "silent" ? { note: VOICEOVER_NOT_INCLUDED } : {}),
|
|
113
|
+
next: `A film takes about 2-5 minutes. ${POLL_GUIDANCE}`,
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "tday_poll_launch_video",
|
|
119
|
+
title: "Poll launch video",
|
|
120
|
+
description: "Check a launch video made with tday_create_launch_video or revised with tday_revise_launch_video. state is in_progress, ready or failed; terminal is true once it is ready or failed. " +
|
|
121
|
+
"Poll every 20-30 seconds while it is in progress. When it is ready, watch it at reviewUrl or call tday_get_launch_video_mp4 for the MP4 file. Spends no credits.",
|
|
122
|
+
inputSchema: argsObject({ videoId: videoIdSchema }),
|
|
123
|
+
outputSchema: outputSchemas.poll,
|
|
124
|
+
annotations: readOnly,
|
|
125
|
+
handler: async (args, config) => {
|
|
126
|
+
const videoId = String(args.videoId);
|
|
127
|
+
const payload = await api(config, safePath("/api/showcase/videos/:videoId", args));
|
|
128
|
+
return summarizeLaunchVideo(videoId, payload ?? {}, reviewUrl(config, videoId));
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "tday_get_launch_video_mp4",
|
|
133
|
+
title: "Get launch video MP4",
|
|
134
|
+
description: "Get a ready launch video as an MP4 file URL. A launch video is ready before its file: " + FIRST_EXPORT + ", so this answers state rendering " +
|
|
135
|
+
"(with progress) until the file exists - call it again every 20-30 seconds until state is ready. Later requests return the stored file at once. Spends no credits. " +
|
|
136
|
+
"If state is failed, call it again with retry: true. versionId defaults to the newest ready cut (readyVersionId from tday_poll_launch_video). " +
|
|
137
|
+
`Pass download: true to also save it inside the current working directory once it is ready (at most ${MAX_VIDEO_BYTES / 1024 / 1024}MB).`,
|
|
138
|
+
inputSchema: argsObject({
|
|
139
|
+
videoId: videoIdSchema,
|
|
140
|
+
versionId: safeIdSchema.optional().describe("A cut's versionId from tday_poll_launch_video. Omit for the newest ready cut."),
|
|
141
|
+
fps: z.union([z.literal(60), z.literal(30)]).default(60).describe("The file's frame rate: 60 (the default, smoothest) or 30 (a smaller file). Older cuts that cannot be rendered again offer only 60 and answer 409 for 30."),
|
|
142
|
+
retry: z.boolean().default(false).describe("Ask again after state failed. Defaults to false."),
|
|
143
|
+
download: z.boolean().default(false).describe("Also save the MP4 into the current working directory. Defaults to false."),
|
|
144
|
+
filePath: z
|
|
145
|
+
.string()
|
|
146
|
+
.trim()
|
|
147
|
+
.min(1)
|
|
148
|
+
.max(500)
|
|
149
|
+
.optional()
|
|
150
|
+
.describe("Where to save it, relative to cwd. Defaults to ./tday-launch-videos/<videoId>-<versionId>.mp4 (<videoId>-<versionId>-30fps.mp4 at 30 fps, so both rates can sit side by side). Only used with download."),
|
|
151
|
+
overwrite: z.boolean().default(false).describe("Whether to overwrite an existing regular file. Defaults to false for safety."),
|
|
152
|
+
}),
|
|
153
|
+
outputSchema: outputSchemas.mp4,
|
|
154
|
+
annotations: writeLocal,
|
|
155
|
+
handler: async (args, config) => {
|
|
156
|
+
const videoId = String(args.videoId);
|
|
157
|
+
const versionId = typeof args.versionId === "string" ? args.versionId : await newestReadyVersion(config, videoId);
|
|
158
|
+
const fps = args.fps === 30 ? 30 : 60;
|
|
159
|
+
// Asks for the file: the stored one, or its export is started (or joined while it renders).
|
|
160
|
+
const answer = await api(config, safePath("/api/showcase/videos/:videoId/render", args), {
|
|
161
|
+
method: "POST",
|
|
162
|
+
body: JSON.stringify({ versionId, ...(fps === 30 ? { fps } : {}), ...(args.retry ? { retry: true } : {}) }),
|
|
163
|
+
});
|
|
164
|
+
const waiting = exportWaiting(answer, { videoId, versionId, fps, reviewUrl: reviewUrl(config, videoId) });
|
|
165
|
+
if (waiting)
|
|
166
|
+
return waiting;
|
|
167
|
+
const mp4Url = answer?.mp4Url;
|
|
168
|
+
if (typeof mp4Url !== "string")
|
|
169
|
+
throw new Error("tday could not prepare the video. Try again in a moment.");
|
|
170
|
+
const film = { videoId, versionId, fps, state: "ready", mp4Url, reviewUrl: reviewUrl(config, videoId) };
|
|
171
|
+
if (!args.download)
|
|
172
|
+
return film;
|
|
173
|
+
const filePath = typeof args.filePath === "string" ? args.filePath : `./tday-launch-videos/${videoId}-${versionId}${fps === 30 ? "-30fps" : ""}.mp4`;
|
|
174
|
+
return { ...film, saved: true, ...(await saveVideo(mp4Url, filePath, Boolean(args.overwrite))) };
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "tday_revise_launch_video",
|
|
179
|
+
title: "Revise launch video",
|
|
180
|
+
description: "Make a new cut of a ready launch video from notes, for example 'slow the ending down' or 'lead with the search box'. The kept film is edited to the notes, so the rest stays as it was. " +
|
|
181
|
+
"It spends tweak credits from the brand's organisation (13 credits for a standard film; tday_get_credit_prices has the full list), and a 402 means the balance cannot cover it. " +
|
|
182
|
+
"A 409 means a cut is already being made: poll until it finishes. The previous cut stays available while the new one is made. " + POLL_GUIDANCE,
|
|
183
|
+
inputSchema: argsObject({
|
|
184
|
+
videoId: videoIdSchema,
|
|
185
|
+
notes: z.string().trim().min(1).max(MAX_DIRECTION_CHARS).describe("What to change, in plain words."),
|
|
186
|
+
}),
|
|
187
|
+
outputSchema: outputSchemas.revise,
|
|
188
|
+
annotations: writeRemote,
|
|
189
|
+
handler: async (args, config) => {
|
|
190
|
+
const videoId = String(args.videoId);
|
|
191
|
+
const cut = await api(config, safePath("/api/showcase/videos/:videoId/regenerate", args), {
|
|
192
|
+
method: "POST",
|
|
193
|
+
body: JSON.stringify({ instructions: args.notes }),
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
videoId,
|
|
197
|
+
...(typeof cut?.versionId === "string" ? { versionId: cut.versionId } : {}),
|
|
198
|
+
status: "queued",
|
|
199
|
+
reviewUrl: reviewUrl(config, videoId),
|
|
200
|
+
next: `A new cut takes a few minutes. ${POLL_GUIDANCE}`,
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
/**
|
|
206
|
+
* The subject is saved before the film is started, so a refused start (out of credits, a plan
|
|
207
|
+
* without the Launch page) leaves it on the brand's Launch page, as a subject a film can be made
|
|
208
|
+
* from later. Said, so the row is not a surprise. MCP keys cannot delete catalog features, on
|
|
209
|
+
* purpose: that scope would let a prompt-injected assistant remove any brand's launch features.
|
|
210
|
+
*/
|
|
211
|
+
function subjectSaved(title) {
|
|
212
|
+
return `The subject "${String(title)}" was saved on the brand's Launch page, where a film can be made from it later.`;
|
|
213
|
+
}
|
|
214
|
+
/** The create route's body: the axes, with the optional ones only when they were given. */
|
|
215
|
+
function launchAxes(args) {
|
|
216
|
+
return {
|
|
217
|
+
length: args.length,
|
|
218
|
+
narration: args.narration,
|
|
219
|
+
music: args.music,
|
|
220
|
+
...(args.musicMood ? { musicMood: args.musicMood } : {}),
|
|
221
|
+
...(args.voiceId ? { voiceId: args.voiceId } : {}),
|
|
222
|
+
...(args.direction ? { direction: args.direction } : {}),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function reviewUrl(config, videoId) {
|
|
226
|
+
return `${config.webBaseUrl}/showcase/review/${encodeURIComponent(videoId)}`;
|
|
227
|
+
}
|
|
228
|
+
async function newestReadyVersion(config, videoId) {
|
|
229
|
+
const payload = await api(config, safePath("/api/showcase/videos/:videoId", { videoId }));
|
|
230
|
+
const versionId = payload?.video?.versionId;
|
|
231
|
+
if (typeof versionId !== "string") {
|
|
232
|
+
throw new Error("This launch video has no finished cut yet. Poll tday_poll_launch_video until it is ready, then try again.");
|
|
233
|
+
}
|
|
234
|
+
return versionId;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The review payload as a poll answer. The newest version decides the state (a revision in
|
|
238
|
+
* flight is in progress even while an older cut is watchable); `readyVersionId` is the newest
|
|
239
|
+
* cut that can be watched or downloaded, which a failed revision leaves in place.
|
|
240
|
+
*/
|
|
241
|
+
function summarizeLaunchVideo(videoId, payload, url) {
|
|
242
|
+
const status = stringOr(payload.feature?.status, "unknown");
|
|
243
|
+
const first = Array.isArray(payload.versions) ? payload.versions[0] : undefined;
|
|
244
|
+
const latest = typeof first === "object" && first !== null ? first : null;
|
|
245
|
+
const latestStatus = stringOr(latest?.status, null);
|
|
246
|
+
const readyVersionId = stringOr(payload.video?.versionId, null);
|
|
247
|
+
const state = launchState(status, latestStatus);
|
|
248
|
+
const failureMessage = state === "failed" ? stringOr(latest?.failureMessage, null) : null;
|
|
249
|
+
// No `mp4Url` here: the review payload's MP4 is the stored file, the clean preview. The
|
|
250
|
+
// file an agent may take away is the MP4 tool's, which asks the render route for the
|
|
251
|
+
// DOWNLOAD (badged for a brand whose downloads carry the Free-tier badge).
|
|
252
|
+
const durationMs = typeof payload.video?.durationMs === "number" ? payload.video.durationMs : null;
|
|
253
|
+
return {
|
|
254
|
+
videoId,
|
|
255
|
+
...(typeof payload.feature?.title === "string" ? { title: payload.feature.title } : {}),
|
|
256
|
+
status,
|
|
257
|
+
state,
|
|
258
|
+
terminal: state !== "in_progress",
|
|
259
|
+
latestVersion: latest
|
|
260
|
+
? { versionId: stringOr(latest.versionId, null), versionNumber: latest.versionNumber ?? null, status: latestStatus }
|
|
261
|
+
: null,
|
|
262
|
+
...(readyVersionId ? { readyVersionId } : {}),
|
|
263
|
+
...(durationMs !== null ? { durationMs } : {}),
|
|
264
|
+
...(failureMessage ? { failureMessage } : {}),
|
|
265
|
+
reviewUrl: url,
|
|
266
|
+
next: nextStep(state, readyVersionId),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/** An export still rendering, or one that failed, as the MP4 tool's answer; null when the file is ready. */
|
|
270
|
+
function exportWaiting(answer, film) {
|
|
271
|
+
if (answer?.status === "rendering" || answer?.status === "none") {
|
|
272
|
+
const progress = typeof answer.progress === "number" ? answer.progress : null;
|
|
273
|
+
return {
|
|
274
|
+
...film,
|
|
275
|
+
state: "rendering",
|
|
276
|
+
progress,
|
|
277
|
+
next: `The file is being rendered${progress === null ? "" : ` (${Math.round(progress * 100)}% done)`}. ${FIRST_EXPORT}. Call tday_get_launch_video_mp4 again in 20-30 seconds.`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
if (answer?.status === "failed") {
|
|
281
|
+
return {
|
|
282
|
+
...film,
|
|
283
|
+
state: "failed",
|
|
284
|
+
error: typeof answer.error === "string" ? answer.error : "The file could not be rendered.",
|
|
285
|
+
next: "The file could not be rendered and spent no credits. Call tday_get_launch_video_mp4 again with retry: true to try again.",
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
function launchState(featureStatus, latestStatus) {
|
|
291
|
+
if (latestStatus === "ready")
|
|
292
|
+
return "ready";
|
|
293
|
+
if (latestStatus === "failed" || featureStatus === "failed")
|
|
294
|
+
return "failed";
|
|
295
|
+
if (!latestStatus && (featureStatus === "review" || featureStatus === "published"))
|
|
296
|
+
return "ready";
|
|
297
|
+
return "in_progress";
|
|
298
|
+
}
|
|
299
|
+
function nextStep(state, readyVersionId) {
|
|
300
|
+
if (state === "ready") {
|
|
301
|
+
return "Ready. Watch it at reviewUrl, call tday_get_launch_video_mp4 for the MP4 file (its first request renders the file, usually a minute or two), or tday_revise_launch_video to change it.";
|
|
302
|
+
}
|
|
303
|
+
if (state === "in_progress")
|
|
304
|
+
return "Still being made. Poll again in 20-30 seconds.";
|
|
305
|
+
return readyVersionId
|
|
306
|
+
? "This cut failed and was not charged. The previous cut is still available: tday_get_launch_video_mp4 returns it, and tday_revise_launch_video can try the change again."
|
|
307
|
+
: "This film failed and was not charged. Start a new one with tday_create_launch_video.";
|
|
308
|
+
}
|
|
309
|
+
function stringOr(value, fallback) {
|
|
310
|
+
return typeof value === "string" && value ? value : fallback;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Save the film inside the working directory. The same guards as tday_download_image
|
|
314
|
+
* (download-safety.ts: the tday CDN allowlist, the cwd sandbox, no symlinks, no silent
|
|
315
|
+
* overwrite), but streamed to disk rather than buffered, since a film is far larger than an
|
|
316
|
+
* image. A refused or interrupted download leaves no partial file behind.
|
|
317
|
+
*/
|
|
318
|
+
async function saveVideo(mp4Url, rawPath, overwrite) {
|
|
319
|
+
const url = validateImageUrl(mp4Url);
|
|
320
|
+
const filePath = await prepareOutputFilePath(rawPath, { overwrite });
|
|
321
|
+
const res = await fetch(url.href);
|
|
322
|
+
if (res.url)
|
|
323
|
+
validateImageUrl(res.url);
|
|
324
|
+
if (!res.ok)
|
|
325
|
+
throw new Error(`Failed to download the video: ${res.status} ${res.statusText}`);
|
|
326
|
+
const declared = Number(res.headers.get("content-length") ?? 0);
|
|
327
|
+
if (Number.isFinite(declared) && declared > MAX_VIDEO_BYTES) {
|
|
328
|
+
throw new Error(`The video exceeds the ${MAX_VIDEO_BYTES / 1024 / 1024}MB download limit. Use mp4Url instead.`);
|
|
329
|
+
}
|
|
330
|
+
const contentType = res.headers.get("content-type")?.toLowerCase();
|
|
331
|
+
if (contentType && !/^(video\/|application\/octet-stream|binary\/octet-stream)/.test(contentType)) {
|
|
332
|
+
throw new Error(`The download is not a video (content-type: ${contentType}).`);
|
|
333
|
+
}
|
|
334
|
+
const reader = res.body?.getReader();
|
|
335
|
+
if (!reader)
|
|
336
|
+
throw new Error("No response body from the video URL.");
|
|
337
|
+
const flags = constants.O_WRONLY |
|
|
338
|
+
constants.O_CREAT |
|
|
339
|
+
(overwrite ? constants.O_TRUNC : constants.O_EXCL) |
|
|
340
|
+
(constants.O_NOFOLLOW ?? 0);
|
|
341
|
+
const handle = await open(filePath, flags, 0o600);
|
|
342
|
+
let size = 0;
|
|
343
|
+
try {
|
|
344
|
+
for (;;) {
|
|
345
|
+
const { done, value } = await reader.read();
|
|
346
|
+
if (done)
|
|
347
|
+
break;
|
|
348
|
+
size += value.byteLength;
|
|
349
|
+
if (size > MAX_VIDEO_BYTES) {
|
|
350
|
+
throw new Error(`The video exceeds the ${MAX_VIDEO_BYTES / 1024 / 1024}MB download limit. Use mp4Url instead.`);
|
|
351
|
+
}
|
|
352
|
+
await handle.write(value);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
await handle.close();
|
|
357
|
+
await rm(filePath, { force: true });
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
reader.releaseLock();
|
|
362
|
+
}
|
|
363
|
+
await handle.close();
|
|
364
|
+
return { path: filePath, size };
|
|
365
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TdayConfig } from "./config.js";
|
|
2
|
+
import { type Tool } from "./tool-schemas.js";
|
|
3
|
+
export type { ResponseFormat, Tool } from "./tool-schemas.js";
|
|
4
|
+
export { responseFormatFromArgs, safePath } from "./tool-schemas.js";
|
|
5
|
+
export { ALLOWED_IMAGE_HOSTS, MAX_IMAGE_BYTES, prepareOutputFilePath, sandboxFilePath, validateImageUrl, } from "./download-safety.js";
|
|
6
|
+
export { formatToolResultText, stringifyResult, toStructuredContent, } from "./result-format.js";
|
|
7
|
+
export { knowledgeTools, KNOWLEDGE_ENTRY_TYPES } from "./tools-knowledge.js";
|
|
8
|
+
export declare const tools: Tool[];
|
|
9
|
+
/**
|
|
10
|
+
* The tools this account is shown: every tool, minus the demo-import tools unless the
|
|
11
|
+
* account may import (tools-demo.ts). `tools` stays the full set so `callTool` can still
|
|
12
|
+
* name them; their routes refuse an account without access.
|
|
13
|
+
*/
|
|
14
|
+
export declare function listedTools(config: TdayConfig): Promise<Tool[]>;
|
|
15
|
+
export declare function callTool(name: string, args: Record<string, unknown>, config: TdayConfig): Promise<unknown>;
|