dsh-plugin-subscriptions 0.3.1 → 0.4.1

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/lib/index.js CHANGED
@@ -2,9 +2,9 @@ import z from "@deepseek-ai/schemastery";
2
2
  import { CONTEXT_WINDOW_EXCEEDED_CODE, CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, attributionHeaders, createUserMessage, errorChain, isContextWindowExceededError, isQuotaExceededError } from "@deepseek-ai/dsh-llm";
3
3
  import { createServer } from "node:http";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
- import { AttachmentId } from "@deepseek-ai/dsh-attachment";
6
5
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
7
6
  import { basename, dirname, join } from "node:path";
7
+ import { AttachmentId } from "@deepseek-ai/dsh-attachment";
8
8
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
9
9
  import { defineTool } from "@deepseek-ai/dsh-tools";
10
10
 
@@ -371,6 +371,8 @@ const IMAGE_MEDIA_TYPES = [
371
371
  "image/webp",
372
372
  "image/gif"
373
373
  ];
374
+ /** Bare MP4 file names the `video` endpoint accepts (no path separators). */
375
+ const VIDEO_NAME_PATTERN = /^[\w.-]+\.mp4$/;
374
376
  /** Payload carried no usable provider id — an RPC client bug, not a server failure. */
375
377
  var BadRequest = class extends Error {};
376
378
  function ok(value) {
@@ -436,6 +438,17 @@ function readImageRef(payload) {
436
438
  ...name$1 === void 0 ? {} : { name: name$1 }
437
439
  };
438
440
  }
441
+ /**
442
+ * Validate the `video` endpoint's payload into a bare file name. Rejecting
443
+ * anything with a path separator (the pattern allows none) pins every read
444
+ * inside the plugin's videos directory.
445
+ */
446
+ function readVideoName(payload) {
447
+ if (typeof payload !== "object" || payload === null) throw new BadRequest("payload must be an object");
448
+ const name$1 = payload.name;
449
+ if (typeof name$1 !== "string" || !VIDEO_NAME_PATTERN.test(name$1)) throw new BadRequest("payload.name must be a bare .mp4 file name");
450
+ return name$1;
451
+ }
439
452
  async function dispatch(controller, endpoint, payload, signal) {
440
453
  switch (endpoint) {
441
454
  case "status": {
@@ -456,6 +469,7 @@ async function dispatch(controller, endpoint, payload, signal) {
456
469
  return ok({ ok: true });
457
470
  case "usage": return ok(await controller.usage(readProvider(payload), signal));
458
471
  case "image": return ok(await controller.readImage(readImageRef(payload), signal));
472
+ case "video": return ok(await controller.readVideo(readVideoName(payload), signal));
459
473
  default: throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
460
474
  }
461
475
  }
@@ -1510,8 +1524,28 @@ function isCodexPermanentRefreshError(error) {
1510
1524
  return error instanceof OAuthEndpointError && error.oauthCode !== void 0 && PERMANENT_REFRESH_CODES.has(error.oauthCode);
1511
1525
  }
1512
1526
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
1527
+ /** Seconds of the canonical 5-hour session and 7-day weekly windows. */
1528
+ const SESSION_WINDOW_SECONDS = 300 * 60;
1529
+ const WEEKLY_WINDOW_SECONDS = 10080 * 60;
1530
+ /** Whether a reported duration approximately matches the expected window length. */
1531
+ function matchesWindow(seconds, expected) {
1532
+ return seconds >= expected * .95 && seconds <= expected * 1.05;
1533
+ }
1534
+ /**
1535
+ * Classify a wham/usage window by its reported duration. The backend has been
1536
+ * observed to place the weekly lane in `primary_window` with no secondary
1537
+ * window, so slot position alone is unreliable; the caller's positional
1538
+ * fallback applies only when the duration is absent.
1539
+ */
1540
+ function codexWindowKind(window, fallback) {
1541
+ const seconds = window.limit_window_seconds;
1542
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return fallback;
1543
+ if (matchesWindow(seconds, SESSION_WINDOW_SECONDS)) return "session";
1544
+ if (matchesWindow(seconds, WEEKLY_WINDOW_SECONDS)) return "weekly";
1545
+ return "other";
1546
+ }
1513
1547
  /** Map one wham/usage window into a {@link UsageWindow}; undefined when unusable. */
1514
- function codexUsageWindow(value, kind) {
1548
+ function codexUsageWindow(value, fallbackKind) {
1515
1549
  if (typeof value !== "object" || value === null) return void 0;
1516
1550
  const window = value;
1517
1551
  if (typeof window.used_percent !== "number" || !Number.isFinite(window.used_percent)) return void 0;
@@ -1519,7 +1553,7 @@ function codexUsageWindow(value, kind) {
1519
1553
  if (typeof window.reset_at === "number" && window.reset_at > 0) resetsAt = window.reset_at * 1e3;
1520
1554
  else if (typeof window.reset_after_seconds === "number" && window.reset_after_seconds > 0) resetsAt = Date.now() + window.reset_after_seconds * 1e3;
1521
1555
  return {
1522
- kind,
1556
+ kind: codexWindowKind(window, fallbackKind),
1523
1557
  usedPercent: window.used_percent,
1524
1558
  ...resetsAt === void 0 ? {} : { resetsAt }
1525
1559
  };
@@ -1527,8 +1561,11 @@ function codexUsageWindow(value, kind) {
1527
1561
  /**
1528
1562
  * Fetch the codex subscription usage from the ChatGPT backend wham/usage
1529
1563
  * endpoint (the source of the codex CLI `/status` rate-limit lines). The
1530
- * primary window is the rolling session (5-hour) lane, the secondary window
1531
- * the weekly lane; the lookup itself consumes no rate-limit budget.
1564
+ * windows are classified by their reported duration (`limit_window_seconds`)
1565
+ * rather than by slot, since the backend has been observed to report the
1566
+ * weekly lane as `primary_window` without a secondary window; slot order is
1567
+ * kept only as a fallback when the duration is absent. The lookup itself
1568
+ * consumes no rate-limit budget.
1532
1569
  * @param session - the stored session (used as-is; never refreshed here).
1533
1570
  * @param fetchFn - fetch implementation (injectable for tests).
1534
1571
  * @param signal - caller cancellation from the RPC transport.
@@ -2893,7 +2930,7 @@ function normalizeHandles(value, field) {
2893
2930
  if (handles.length > MAX_HANDLES) throw new Error(`x_search: ${field} supports at most ${MAX_HANDLES} handles`);
2894
2931
  return handles;
2895
2932
  }
2896
- function isRecord(value) {
2933
+ function isRecord$1(value) {
2897
2934
  return typeof value === "object" && value !== null && !Array.isArray(value);
2898
2935
  }
2899
2936
  /**
@@ -2902,7 +2939,7 @@ function isRecord(value) {
2902
2939
  * top-level `citations` and inline `url_citation` annotations for sources.
2903
2940
  */
2904
2941
  function parseXSearchResponse(payload) {
2905
- const body = isRecord(payload) ? payload : {};
2942
+ const body = isRecord$1(payload) ? payload : {};
2906
2943
  let answer = typeof body.output_text === "string" ? body.output_text.trim() : "";
2907
2944
  const citations = [];
2908
2945
  const push = (url) => {
@@ -2911,12 +2948,12 @@ function parseXSearchResponse(payload) {
2911
2948
  if (Array.isArray(body.citations)) for (const citation of body.citations) push(citation);
2912
2949
  const parts = [];
2913
2950
  if (Array.isArray(body.output)) for (const item of body.output) {
2914
- if (!isRecord(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
2951
+ if (!isRecord$1(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
2915
2952
  for (const part of item.content) {
2916
- if (!isRecord(part)) continue;
2953
+ if (!isRecord$1(part)) continue;
2917
2954
  if ((part.type === "output_text" || part.type === "text") && typeof part.text === "string" && part.text.trim().length > 0) parts.push(part.text.trim());
2918
2955
  if (Array.isArray(part.annotations)) {
2919
- for (const annotation of part.annotations) if (isRecord(annotation) && annotation.type === "url_citation") push(annotation.url);
2956
+ for (const annotation of part.annotations) if (isRecord$1(annotation) && annotation.type === "url_citation") push(annotation.url);
2920
2957
  }
2921
2958
  }
2922
2959
  }
@@ -2927,7 +2964,7 @@ function parseXSearchResponse(payload) {
2927
2964
  };
2928
2965
  }
2929
2966
  /** Bound a call-card title's query. */
2930
- function truncate$1(text, max = 60) {
2967
+ function truncate$2(text, max = 60) {
2931
2968
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
2932
2969
  }
2933
2970
  /**
@@ -2999,11 +3036,11 @@ function createXSearchTool(options) {
2999
3036
  },
3000
3037
  presentCall: (args) => ({
3001
3038
  card: "generic",
3002
- title: `x_search: ${truncate$1(args.query)}`,
3039
+ title: `x_search: ${truncate$2(args.query)}`,
3003
3040
  kind: "search"
3004
3041
  }),
3005
3042
  presentResult: (_args, result) => {
3006
- if (result.isError || !isRecord(result.meta)) return void 0;
3043
+ if (result.isError || !isRecord$1(result.meta)) return void 0;
3007
3044
  return {
3008
3045
  card: "web",
3009
3046
  kind: "search",
@@ -3041,13 +3078,17 @@ function createXSearchTool(options) {
3041
3078
 
3042
3079
  //#endregion
3043
3080
  //#region src/tools/image-generate.ts
3044
- /** Endpoint the generation request is posted to. */
3081
+ /** Endpoint the codex generation request is posted to. */
3045
3082
  const IMAGE_GENERATE_URL = "https://chatgpt.com/backend-api/codex/images/generations";
3046
3083
  /** The image model the codex subscription endpoint serves. */
3047
3084
  const IMAGE_GENERATE_MODEL = "gpt-image-2";
3085
+ /** Endpoint the grok generation request is posted to. */
3086
+ const GROK_IMAGE_GENERATE_URL = "https://api.x.ai/v1/images/generations";
3087
+ /** The image model the grok subscription endpoint serves. */
3088
+ const GROK_IMAGE_GENERATE_MODEL = "grok-imagine-image-2.0";
3048
3089
  /**
3049
- * Assemble the request body from tool arguments (hand-checks the non-empty
3050
- * prompt the schema DSL cannot express).
3090
+ * Assemble the codex request body from tool arguments (hand-checks the
3091
+ * non-empty prompt the schema DSL cannot express).
3051
3092
  */
3052
3093
  function buildImageGenerateBody(args) {
3053
3094
  const prompt = args.prompt.trim();
@@ -3059,6 +3100,30 @@ function buildImageGenerateBody(args) {
3059
3100
  ...args.quality === void 0 ? {} : { quality: args.quality }
3060
3101
  };
3061
3102
  }
3103
+ /** The codex `size` values mapped onto grok aspect ratios. */
3104
+ const GROK_ASPECT_RATIOS = {
3105
+ "1024x1024": "1:1",
3106
+ "1024x1536": "2:3",
3107
+ "1536x1024": "3:2",
3108
+ "auto": "auto"
3109
+ };
3110
+ /**
3111
+ * Assemble the grok request body from the same tool arguments: `size` maps
3112
+ * onto the nearest `aspect_ratio`, and `quality` folds into grok's low/medium
3113
+ * pair (`high` → `medium`, `auto` → provider default).
3114
+ */
3115
+ function buildGrokImageGenerateBody(args) {
3116
+ const prompt = args.prompt.trim();
3117
+ if (prompt.length === 0) throw new Error("image_generate: prompt must be a non-empty string");
3118
+ const quality = args.quality === "low" ? "low" : args.quality === "medium" || args.quality === "high" ? "medium" : void 0;
3119
+ return {
3120
+ prompt,
3121
+ model: GROK_IMAGE_GENERATE_MODEL,
3122
+ response_format: "b64_json",
3123
+ ...args.size === void 0 ? {} : { aspect_ratio: GROK_ASPECT_RATIOS[args.size] },
3124
+ ...quality === void 0 ? {} : { quality }
3125
+ };
3126
+ }
3062
3127
  /**
3063
3128
  * Parse the generations response into decodable images. Throws when the
3064
3129
  * payload carries no usable `b64_json` entries.
@@ -3079,16 +3144,32 @@ function parseImageGenerateResponse(payload) {
3079
3144
  if (images.length === 0) throw new Error("image_generate: the response carried no image data");
3080
3145
  return images;
3081
3146
  }
3082
- /** Directory the generated PNG files are written to. */
3147
+ /** Directory the generated image files are written to. */
3083
3148
  function imagesDirectory() {
3084
3149
  return dshHomePath("plugins", "subscriptions", "images");
3085
3150
  }
3151
+ /**
3152
+ * Sniff a generated image's media type from its magic bytes (codex serves
3153
+ * PNG; grok's format is undocumented, so trust the bytes). Unrecognized data
3154
+ * defaults to PNG, matching the historical behavior.
3155
+ */
3156
+ function sniffImageMediaType(data) {
3157
+ if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) return "image/jpeg";
3158
+ if (data.length >= 12 && data.toString("latin1", 0, 4) === "RIFF" && data.toString("latin1", 8, 12) === "WEBP") return "image/webp";
3159
+ return "image/png";
3160
+ }
3161
+ /** File extension for one sniffed media type. */
3162
+ const MEDIA_TYPE_EXTENSIONS = {
3163
+ "image/png": "png",
3164
+ "image/jpeg": "jpg",
3165
+ "image/webp": "webp"
3166
+ };
3086
3167
  /** Timestamped, collision-safe file name for one generated image. */
3087
- function imageFileName(index) {
3088
- return `image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}-${index}.png`;
3168
+ function imageFileName(index, mediaType) {
3169
+ return `image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}-${index}.${MEDIA_TYPE_EXTENSIONS[mediaType]}`;
3089
3170
  }
3090
3171
  /** Bound a call-card title's prompt. */
3091
- function truncate(text, max = 60) {
3172
+ function truncate$1(text, max = 60) {
3092
3173
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
3093
3174
  }
3094
3175
  /**
@@ -3143,7 +3224,7 @@ function imageGenerateText(value) {
3143
3224
  function createImageGenerateTool(options) {
3144
3225
  return defineTool({
3145
3226
  name: "image_generate",
3146
- description: "Generate an image with the ChatGPT subscription (gpt-image-2) and save it as a PNG file. Returns the saved file paths; on image-capable models the image itself is attached.",
3227
+ description: "Generate an image with the ChatGPT subscription (gpt-image-2) or the Grok subscription (grok-imagine-image-2.0) and save it as an image file. The `provider` parameter picks the preferred provider (default gpt); when the preferred one is logged out the other serves as fallback. Returns the saved file paths; on image-capable models the image itself is attached.",
3147
3228
  parameters: {
3148
3229
  prompt: {
3149
3230
  type: "string",
@@ -3169,6 +3250,11 @@ function createImageGenerateTool(options) {
3169
3250
  "auto"
3170
3251
  ],
3171
3252
  description: "Rendering quality; omit for the provider default."
3253
+ },
3254
+ provider: {
3255
+ type: "string",
3256
+ enum: ["gpt", "grok"],
3257
+ description: "Preferred provider (default gpt); the other one serves as fallback when the preferred is logged out."
3172
3258
  }
3173
3259
  },
3174
3260
  output: {
@@ -3224,36 +3310,64 @@ function createImageGenerateTool(options) {
3224
3310
  },
3225
3311
  presentCall: (args) => ({
3226
3312
  card: "generic",
3227
- title: `image_generate: ${truncate(args.prompt)}`
3313
+ title: `image_generate: ${truncate$1(args.prompt)}`
3228
3314
  }),
3229
3315
  presentResult: (_args, result) => ({
3230
3316
  card: "generic",
3231
3317
  content: result.content.filter((block) => block.type === "text")
3232
3318
  }),
3233
3319
  async execute(args, exec) {
3234
- const body = buildImageGenerateBody(args);
3235
- const session = await options.tokens.session();
3236
- const response = await (options.fetchFn ?? fetch)(IMAGE_GENERATE_URL, {
3237
- method: "POST",
3238
- headers: {
3239
- "authorization": `Bearer ${session.accessToken}`,
3240
- "chatgpt-account-id": session.accountId,
3241
- "originator": "codex_cli_rs",
3242
- "content-type": "application/json",
3243
- "accept": "application/json"
3244
- },
3245
- body: JSON.stringify(body),
3246
- signal: exec.signal
3247
- });
3320
+ const fetchFn = options.fetchFn ?? fetch;
3321
+ const preferGrok = args.provider === "grok";
3322
+ const codexReady = options.codexTokens !== void 0 && await options.codexTokens.hasSession();
3323
+ const grokReady = options.grokTokens !== void 0 && await options.grokTokens.hasSession();
3324
+ const useGrok = preferGrok ? grokReady : grokReady && !codexReady;
3325
+ const useCodex = !useGrok && codexReady;
3326
+ let response;
3327
+ if (useCodex && options.codexTokens !== void 0) {
3328
+ const session = await options.codexTokens.session();
3329
+ response = await fetchFn(IMAGE_GENERATE_URL, {
3330
+ method: "POST",
3331
+ headers: {
3332
+ "authorization": `Bearer ${session.accessToken}`,
3333
+ "chatgpt-account-id": session.accountId,
3334
+ "originator": "codex_cli_rs",
3335
+ "content-type": "application/json",
3336
+ "accept": "application/json"
3337
+ },
3338
+ body: JSON.stringify(buildImageGenerateBody(args)),
3339
+ signal: exec.signal
3340
+ });
3341
+ } else if (useGrok && options.grokTokens !== void 0) {
3342
+ const session = await options.grokTokens.session();
3343
+ response = await fetchFn(GROK_IMAGE_GENERATE_URL, {
3344
+ method: "POST",
3345
+ headers: {
3346
+ "authorization": `Bearer ${session.accessToken}`,
3347
+ "content-type": "application/json",
3348
+ "accept": "application/json"
3349
+ },
3350
+ body: JSON.stringify(buildGrokImageGenerateBody(args)),
3351
+ signal: exec.signal
3352
+ });
3353
+ } else {
3354
+ const manager = preferGrok ? options.grokTokens ?? options.codexTokens : options.codexTokens ?? options.grokTokens;
3355
+ if (manager === void 0) throw new Error("image_generate: no image provider is configured");
3356
+ await manager.session();
3357
+ throw new Error("image_generate: no image provider is logged in");
3358
+ }
3248
3359
  if (!response.ok) throw await httpLlmError(response, "image_generate");
3249
3360
  const images = parseImageGenerateResponse(await response.json());
3250
3361
  const directory = options.imagesDir ?? imagesDirectory();
3251
3362
  await mkdir(directory, { recursive: true });
3252
3363
  const paths = [];
3364
+ const mediaTypes = [];
3253
3365
  for (const [index, image] of images.entries()) {
3254
- const path = join(directory, imageFileName(index));
3366
+ const mediaType = sniffImageMediaType(image.data);
3367
+ const path = join(directory, imageFileName(index, mediaType));
3255
3368
  await writeFile(path, image.data);
3256
3369
  paths.push(path);
3370
+ mediaTypes.push(mediaType);
3257
3371
  }
3258
3372
  const attachments = options.resolveAttachments?.();
3259
3373
  const imageCapable = attachments !== void 0 && await routeDeclaresImageInput(options.resolveLlm, exec);
@@ -3261,7 +3375,7 @@ function createImageGenerateTool(options) {
3261
3375
  if (attachments !== void 0 && imageCapable) for (const [index, image] of images.entries()) {
3262
3376
  const ref = await attachments.saveImage({
3263
3377
  data: image.data,
3264
- mediaType: "image/png",
3378
+ mediaType: mediaTypes[index],
3265
3379
  name: basename(paths[index])
3266
3380
  });
3267
3381
  refs.push({
@@ -3291,6 +3405,249 @@ function createImageGenerateTool(options) {
3291
3405
  });
3292
3406
  }
3293
3407
 
3408
+ //#endregion
3409
+ //#region src/tools/video-generate.ts
3410
+ /** Endpoint the generation request is posted to. */
3411
+ const VIDEO_GENERATE_URL = "https://api.x.ai/v1/videos/generations";
3412
+ /** The video model the grok subscription endpoint serves. */
3413
+ const VIDEO_GENERATE_MODEL = "grok-imagine-video-1.5";
3414
+ /** Polling endpoint for one generation request. */
3415
+ function videoStatusUrl(requestId) {
3416
+ return `https://api.x.ai/v1/videos/${encodeURIComponent(requestId)}`;
3417
+ }
3418
+ /** Default delay between two status polls. */
3419
+ const DEFAULT_POLL_INTERVAL_MS = 3e3;
3420
+ /** Default overall deadline for one generation (submit → done). */
3421
+ const DEFAULT_MAX_WAIT_MS = 10 * 6e4;
3422
+ /** xAI's supported clip length range in seconds. */
3423
+ const DURATION_RANGE = {
3424
+ min: 1,
3425
+ max: 15
3426
+ };
3427
+ /**
3428
+ * Assemble the request body from tool arguments (hand-checks the non-empty
3429
+ * prompt and the duration range the schema DSL cannot express).
3430
+ */
3431
+ function buildVideoGenerateBody(args) {
3432
+ const prompt = args.prompt.trim();
3433
+ if (prompt.length === 0) throw new Error("video_generate: prompt must be a non-empty string");
3434
+ if (args.duration !== void 0 && (!Number.isInteger(args.duration) || args.duration < DURATION_RANGE.min || args.duration > DURATION_RANGE.max)) throw new Error(`video_generate: duration must be an integer between ${String(DURATION_RANGE.min)} and ${String(DURATION_RANGE.max)} seconds`);
3435
+ const imageUrl = args.image_url?.trim();
3436
+ return {
3437
+ prompt,
3438
+ model: VIDEO_GENERATE_MODEL,
3439
+ ...args.duration === void 0 ? {} : { duration: args.duration },
3440
+ ...args.aspect_ratio === void 0 ? {} : { aspect_ratio: args.aspect_ratio },
3441
+ ...args.resolution === void 0 ? {} : { resolution: args.resolution },
3442
+ ...imageUrl === void 0 || imageUrl.length === 0 ? {} : { image: { url: imageUrl } }
3443
+ };
3444
+ }
3445
+ function isRecord(value) {
3446
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3447
+ }
3448
+ /**
3449
+ * Extract the request id from the submit response. Throws when the payload
3450
+ * carries none.
3451
+ */
3452
+ function parseVideoStartResponse(payload) {
3453
+ const body = isRecord(payload) ? payload : {};
3454
+ if (typeof body.request_id !== "string" || body.request_id.length === 0) throw new Error("video_generate: the response carried no request_id");
3455
+ return body.request_id;
3456
+ }
3457
+ /**
3458
+ * Decode one poll response. A `done` payload without a video URL and an
3459
+ * unrecognized status both throw (the poll loop cannot make progress on
3460
+ * either).
3461
+ */
3462
+ function parseVideoStatusResponse(payload) {
3463
+ const body = isRecord(payload) ? payload : {};
3464
+ switch (body.status) {
3465
+ case "pending": return { status: "pending" };
3466
+ case "done": {
3467
+ const video = isRecord(body.video) ? body.video : {};
3468
+ if (typeof video.url !== "string" || video.url.length === 0) throw new Error("video_generate: the completed response carried no video URL");
3469
+ return {
3470
+ status: "done",
3471
+ url: video.url,
3472
+ ...typeof video.duration === "number" ? { duration: video.duration } : {}
3473
+ };
3474
+ }
3475
+ case "failed":
3476
+ case "expired": {
3477
+ const error = isRecord(body.error) ? body.error : {};
3478
+ const detail = typeof error.message === "string" && error.message.length > 0 ? error.message : typeof body.error === "string" && body.error.length > 0 ? body.error : void 0;
3479
+ return {
3480
+ status: body.status,
3481
+ ...detail === void 0 ? {} : { detail }
3482
+ };
3483
+ }
3484
+ default: throw new Error(`video_generate: unexpected status ${JSON.stringify(body.status)}`);
3485
+ }
3486
+ }
3487
+ /** Directory the downloaded MP4 files are written to. */
3488
+ function videosDirectory() {
3489
+ return dshHomePath("plugins", "subscriptions", "videos");
3490
+ }
3491
+ /** Timestamped, collision-safe file name for one generated video. */
3492
+ function videoFileName() {
3493
+ return `video-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}.mp4`;
3494
+ }
3495
+ /** Bound a call-card title's prompt. */
3496
+ function truncate(text, max = 60) {
3497
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
3498
+ }
3499
+ /** Abort-aware sleep between two polls. */
3500
+ function sleep(ms, signal) {
3501
+ if (ms <= 0) return Promise.resolve();
3502
+ return new Promise((resolve, reject) => {
3503
+ const onAbort = () => {
3504
+ clearTimeout(timer);
3505
+ reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("video_generate: aborted"));
3506
+ };
3507
+ const timer = setTimeout(() => {
3508
+ signal.removeEventListener("abort", onAbort);
3509
+ resolve();
3510
+ }, ms);
3511
+ if (signal.aborted) {
3512
+ onAbort();
3513
+ return;
3514
+ }
3515
+ signal.addEventListener("abort", onAbort, { once: true });
3516
+ });
3517
+ }
3518
+ /**
3519
+ * Build the `video_generate` tool definition.
3520
+ * @param options - grok session source, fetch implementation, and video directory.
3521
+ * @returns the tool to register on `ctx.tools`.
3522
+ */
3523
+ function createVideoGenerateTool(options) {
3524
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
3525
+ const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
3526
+ return defineTool({
3527
+ name: "video_generate",
3528
+ description: `Generate a short video (1-15 seconds) with the grok subscription (${VIDEO_GENERATE_MODEL}) and save it as an MP4 file. Generation is asynchronous and may take a minute or more; the tool waits for completion and returns the saved file path. Optionally animate a still image by passing image_url (image-to-video).`,
3529
+ parameters: {
3530
+ prompt: {
3531
+ type: "string",
3532
+ required: true,
3533
+ description: "What the video should show."
3534
+ },
3535
+ duration: {
3536
+ type: "integer",
3537
+ description: "Clip length in seconds (1-15); omit for the provider default."
3538
+ },
3539
+ aspect_ratio: {
3540
+ type: "string",
3541
+ enum: [
3542
+ "16:9",
3543
+ "9:16",
3544
+ "1:1",
3545
+ "4:3",
3546
+ "3:4",
3547
+ "3:2",
3548
+ "2:3"
3549
+ ],
3550
+ description: "Output aspect ratio; omit for the provider default (16:9)."
3551
+ },
3552
+ resolution: {
3553
+ type: "string",
3554
+ enum: [
3555
+ "480p",
3556
+ "720p",
3557
+ "1080p"
3558
+ ],
3559
+ description: "Output resolution; omit for the provider default (480p). Higher is slower."
3560
+ },
3561
+ image_url: {
3562
+ type: "string",
3563
+ description: "Optional public URL or base64 data URL of a JPEG/PNG/WebP image to animate (image-to-video); the image becomes the starting frame."
3564
+ }
3565
+ },
3566
+ output: {
3567
+ schema: {
3568
+ type: "object",
3569
+ properties: {
3570
+ path: {
3571
+ type: "string",
3572
+ required: true
3573
+ },
3574
+ url: {
3575
+ type: "string",
3576
+ required: true
3577
+ },
3578
+ duration: { type: "number" }
3579
+ },
3580
+ additionalProperties: false
3581
+ },
3582
+ render: (_args, value) => [{
3583
+ type: "text",
3584
+ text: `Saved video to ${value.path}` + (value.duration === void 0 ? "" : ` (${String(value.duration)}s)`) + `\nTemporary provider URL (expires soon): ${value.url}`
3585
+ }],
3586
+ presentationMeta: (_args, value) => ({
3587
+ fileName: basename(value.path),
3588
+ ...value.duration === void 0 ? {} : { duration: value.duration }
3589
+ })
3590
+ },
3591
+ presentCall: (args) => ({
3592
+ card: "generic",
3593
+ title: `video_generate: ${truncate(args.prompt)}`
3594
+ }),
3595
+ async execute(args, exec) {
3596
+ const body = buildVideoGenerateBody(args);
3597
+ const session = await options.tokens.session();
3598
+ const fetchFn = options.fetchFn ?? fetch;
3599
+ const headers = {
3600
+ "authorization": `Bearer ${session.accessToken}`,
3601
+ "accept": "application/json"
3602
+ };
3603
+ const submit = await fetchFn(VIDEO_GENERATE_URL, {
3604
+ method: "POST",
3605
+ headers: {
3606
+ ...headers,
3607
+ "content-type": "application/json"
3608
+ },
3609
+ body: JSON.stringify(body),
3610
+ signal: exec.signal
3611
+ });
3612
+ if (!submit.ok) throw await httpLlmError(submit, "video_generate");
3613
+ const requestId = parseVideoStartResponse(await submit.json());
3614
+ const deadline = Date.now() + maxWaitMs;
3615
+ let done;
3616
+ for (;;) {
3617
+ await sleep(pollIntervalMs, exec.signal);
3618
+ const poll = await fetchFn(videoStatusUrl(requestId), {
3619
+ method: "GET",
3620
+ headers,
3621
+ signal: exec.signal
3622
+ });
3623
+ if (!poll.ok) throw await httpLlmError(poll, "video_generate");
3624
+ const status = parseVideoStatusResponse(await poll.json());
3625
+ if (status.status === "done") {
3626
+ done = status;
3627
+ break;
3628
+ }
3629
+ if (status.status === "failed" || status.status === "expired") throw new Error(`video_generate: generation ${status.status} (request ${requestId})` + (status.detail === void 0 ? "" : `: ${status.detail}`));
3630
+ if (Date.now() >= deadline) throw new Error(`video_generate: timed out after ${String(maxWaitMs)}ms waiting for request ${requestId}`);
3631
+ }
3632
+ const download = await fetchFn(done.url, {
3633
+ method: "GET",
3634
+ signal: exec.signal
3635
+ });
3636
+ if (!download.ok) throw await httpLlmError(download, "video_generate download");
3637
+ const data = Buffer.from(await download.arrayBuffer());
3638
+ const directory = options.videosDir ?? videosDirectory();
3639
+ await mkdir(directory, { recursive: true });
3640
+ const path = join(directory, videoFileName());
3641
+ await writeFile(path, data);
3642
+ return {
3643
+ path,
3644
+ url: done.url,
3645
+ ...done.duration === void 0 ? {} : { duration: done.duration }
3646
+ };
3647
+ }
3648
+ });
3649
+ }
3650
+
3294
3651
  //#endregion
3295
3652
  //#region src/index.ts
3296
3653
  const name = "dsh-plugin-subscriptions";
@@ -3420,6 +3777,12 @@ var SubscriptionsAuthController = class {
3420
3777
  dataBase64: Buffer.from(stored.data).toString("base64")
3421
3778
  };
3422
3779
  }
3780
+ async readVideo(name$1, signal) {
3781
+ return {
3782
+ mediaType: "video/mp4",
3783
+ dataBase64: (await readFile(join(videosDirectory(), name$1), { signal })).toString("base64")
3784
+ };
3785
+ }
3423
3786
  async status(provider) {
3424
3787
  const session = await getSession(provider);
3425
3788
  const account = accountOf(provider, session);
@@ -3577,9 +3940,13 @@ function apply(ctx, config) {
3577
3940
  }
3578
3941
  registerAuthRpc(ctx, new SubscriptionsAuthController(flows, authChanged, resolveAttachments, usageFetchers));
3579
3942
  ctx.inject(["tools"], (toolsCtx) => {
3580
- if (grokTokens !== void 0) toolsCtx.tools.register(createXSearchTool({ tokens: grokTokens }));
3581
- if (codexTokens !== void 0) toolsCtx.tools.register(createImageGenerateTool({
3582
- tokens: codexTokens,
3943
+ if (grokTokens !== void 0) {
3944
+ toolsCtx.tools.register(createXSearchTool({ tokens: grokTokens }));
3945
+ toolsCtx.tools.register(createVideoGenerateTool({ tokens: grokTokens }));
3946
+ }
3947
+ if (codexTokens !== void 0 || grokTokens !== void 0) toolsCtx.tools.register(createImageGenerateTool({
3948
+ ...codexTokens === void 0 ? {} : { codexTokens },
3949
+ ...grokTokens === void 0 ? {} : { grokTokens },
3583
3950
  resolveAttachments,
3584
3951
  resolveLlm: () => ctx.get("llm")
3585
3952
  }));
@@ -57,8 +57,11 @@ export declare const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usa
57
57
  /**
58
58
  * Fetch the codex subscription usage from the ChatGPT backend wham/usage
59
59
  * endpoint (the source of the codex CLI `/status` rate-limit lines). The
60
- * primary window is the rolling session (5-hour) lane, the secondary window
61
- * the weekly lane; the lookup itself consumes no rate-limit budget.
60
+ * windows are classified by their reported duration (`limit_window_seconds`)
61
+ * rather than by slot, since the backend has been observed to report the
62
+ * weekly lane as `primary_window` without a secondary window; slot order is
63
+ * kept only as a fallback when the duration is absent. The lookup itself
64
+ * consumes no rate-limit budget.
62
65
  * @param session - the stored session (used as-is; never refreshed here).
63
66
  * @param fetchFn - fetch implementation (injectable for tests).
64
67
  * @param signal - caller cancellation from the RPC transport.