dsh-plugin-subscriptions 0.3.0 → 0.4.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/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
  }
@@ -678,14 +692,26 @@ var TokenManager = class {
678
692
  /** How long a discovered catalog is trusted before re-fetching. */
679
693
  const DISCOVERY_TTL_MS = 5 * 6e4;
680
694
  /**
681
- * TTL cache for one provider's discovered model catalog. Only `listModels`
682
- * populates it (via {@link get}); `resolveModel` reads {@link cached} so it
683
- * never performs network I/O. A 401 during a fetch must call
684
- * {@link invalidate}.
695
+ * Cache for one provider's discovered model catalog. The TTL only decides
696
+ * when to REFRESH; it never makes the cache forget: capability metadata
697
+ * (reasoning efforts) must stay stable for a session that selected an effort,
698
+ * or mid-conversation calls fail UNSUPPORTED_REASONING_EFFORT the moment the
699
+ * cache goes stale. `listModels` awaits freshness via {@link get};
700
+ * `resolveModel` uses {@link resolve}, which serves the last-known catalog
701
+ * while a stale entry refreshes in the background, and only awaits the fetch
702
+ * when nothing is known yet. An optional {@link CatalogPersistence} seeds the
703
+ * last-known state across restarts and receives every successful fetch. A 401
704
+ * during a fetch must call {@link invalidate}.
685
705
  */
686
706
  var ModelCatalogCache = class {
687
707
  entry;
688
- constructor(ttlMs = DISCOVERY_TTL_MS) {
708
+ inflight;
709
+ /** Settles once the persisted snapshot (when any) has been considered. */
710
+ seeded;
711
+ /** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
712
+ seedDisabled = false;
713
+ constructor(persistence, ttlMs = DISCOVERY_TTL_MS) {
714
+ this.persistence = persistence;
689
715
  this.ttlMs = ttlMs;
690
716
  }
691
717
  /**
@@ -696,27 +722,200 @@ var ModelCatalogCache = class {
696
722
  if (this.entry === void 0 || Date.now() - this.entry.at >= this.ttlMs) return void 0;
697
723
  return this.entry.models;
698
724
  }
725
+ /** Load the persisted snapshot once; a fetch or invalidate that landed first wins. */
726
+ ensureSeeded() {
727
+ if (this.persistence === void 0) return Promise.resolve();
728
+ this.seeded ??= this.persistence.load().then((snapshot) => {
729
+ if (snapshot !== void 0 && this.entry === void 0 && !this.seedDisabled) this.entry = snapshot;
730
+ }, () => void 0);
731
+ return this.seeded;
732
+ }
733
+ /** Run (or join) the single in-flight fetch, updating memory and disk on success. */
734
+ refresh(fetcher) {
735
+ this.inflight ??= fetcher().then((models) => {
736
+ const snapshot = {
737
+ at: Date.now(),
738
+ models
739
+ };
740
+ this.entry = snapshot;
741
+ this.persistence?.save(snapshot).catch(() => void 0);
742
+ return models;
743
+ }).finally(() => {
744
+ this.inflight = void 0;
745
+ });
746
+ return this.inflight;
747
+ }
699
748
  /**
700
749
  * Return the cached catalog when fresh, otherwise fetch and cache it.
701
750
  * @param fetcher - performs the provider's model-list request.
702
751
  * @returns the discovered models.
752
+ * @throws the fetcher's failure (the `listModels` caller warns and falls back).
703
753
  */
704
754
  async get(fetcher) {
705
- const cached = this.cached();
706
- if (cached !== void 0) return cached;
707
- const models = await fetcher();
708
- this.entry = {
709
- at: Date.now(),
710
- models
711
- };
712
- return models;
755
+ await this.ensureSeeded();
756
+ return this.cached() ?? this.refresh(fetcher);
757
+ }
758
+ /**
759
+ * The models for capability resolution. A fresh cache answers directly; a
760
+ * stale one answers immediately from the last-known catalog while a
761
+ * background refresh runs (a mid-conversation `resolveModel` must neither
762
+ * block on nor fail with the network); a cold cache awaits one fetch.
763
+ * @param fetcher - performs the provider's model-list request.
764
+ * @returns the models, or `undefined` when nothing is known (the caller
765
+ * falls back to its static metadata). Never throws.
766
+ */
767
+ async resolve(fetcher) {
768
+ await this.ensureSeeded();
769
+ const fresh = this.cached();
770
+ if (fresh !== void 0) return fresh;
771
+ const known = this.entry?.models;
772
+ if (known !== void 0) {
773
+ this.refresh(fetcher).catch(() => void 0);
774
+ return known;
775
+ }
776
+ try {
777
+ return await this.refresh(fetcher);
778
+ } catch {
779
+ return;
780
+ }
713
781
  }
714
782
  /** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
715
783
  invalidate() {
716
784
  this.entry = void 0;
785
+ this.seedDisabled = true;
786
+ this.persistence?.clear().catch(() => void 0);
717
787
  }
718
788
  };
719
789
 
790
+ //#endregion
791
+ //#region src/providers/catalog-store.ts
792
+ /**
793
+ * Absolute path of the catalog store file.
794
+ * @returns `dshHomePath('plugins', 'subscriptions', 'models.json')`.
795
+ */
796
+ function modelsFilePath() {
797
+ return dshHomePath("plugins", "subscriptions", "models.json");
798
+ }
799
+ /** Validate one persisted reasoning block, or undefined when malformed. */
800
+ function sanitizeReasoning(value) {
801
+ if (typeof value !== "object" || value === null) return void 0;
802
+ const raw = value;
803
+ if (!Array.isArray(raw.efforts) || raw.efforts.length === 0) return void 0;
804
+ const seen = /* @__PURE__ */ new Set();
805
+ const efforts = [];
806
+ for (const entry of raw.efforts) {
807
+ if (typeof entry !== "object" || entry === null) return void 0;
808
+ const effort = entry;
809
+ if (typeof effort.id !== "string" || effort.id.length === 0 || typeof effort.name !== "string" || effort.name.length === 0 || effort.description !== void 0 && typeof effort.description !== "string" || seen.has(effort.id)) return void 0;
810
+ seen.add(effort.id);
811
+ efforts.push({
812
+ id: ReasoningEffortId(effort.id),
813
+ name: effort.name,
814
+ ...effort.description === void 0 ? {} : { description: effort.description }
815
+ });
816
+ }
817
+ if (raw.defaultEffort !== void 0 && (typeof raw.defaultEffort !== "string" || !seen.has(raw.defaultEffort))) return void 0;
818
+ return {
819
+ efforts,
820
+ ...raw.defaultEffort === void 0 ? {} : { defaultEffort: ReasoningEffortId(raw.defaultEffort) }
821
+ };
822
+ }
823
+ /** Validate one persisted model, or undefined when malformed. */
824
+ function sanitizeModel(value) {
825
+ if (typeof value !== "object" || value === null) return void 0;
826
+ const raw = value;
827
+ if (typeof raw.id !== "string" || raw.id.length === 0 || typeof raw.name !== "string" || raw.name.length === 0 || raw.description !== void 0 && typeof raw.description !== "string" || raw.contextWindow !== void 0 && (typeof raw.contextWindow !== "number" || !Number.isInteger(raw.contextWindow) || raw.contextWindow <= 0) || raw.priority !== void 0 && (typeof raw.priority !== "number" || !Number.isFinite(raw.priority))) return void 0;
828
+ const reasoning = raw.reasoning === void 0 ? void 0 : sanitizeReasoning(raw.reasoning);
829
+ if (raw.reasoning !== void 0 && reasoning === void 0) return void 0;
830
+ return {
831
+ id: raw.id,
832
+ name: raw.name,
833
+ ...raw.description === void 0 ? {} : { description: raw.description },
834
+ ...raw.contextWindow === void 0 ? {} : { contextWindow: raw.contextWindow },
835
+ ...raw.priority === void 0 ? {} : { priority: raw.priority },
836
+ ...reasoning === void 0 ? {} : { reasoning }
837
+ };
838
+ }
839
+ /**
840
+ * Validate one persisted snapshot. Strict: any malformed field drops the
841
+ * whole snapshot rather than repairing it — the next successful discovery
842
+ * rewrites the entry anyway.
843
+ * @param value - the raw per-provider file entry.
844
+ * @returns the validated snapshot, or undefined when unusable.
845
+ */
846
+ function sanitizeSnapshot(value) {
847
+ if (typeof value !== "object" || value === null) return void 0;
848
+ const raw = value;
849
+ if (typeof raw.at !== "number" || !Number.isFinite(raw.at)) return void 0;
850
+ if (!Array.isArray(raw.models) || raw.models.length === 0) return void 0;
851
+ const seen = /* @__PURE__ */ new Set();
852
+ const models = [];
853
+ for (const entry of raw.models) {
854
+ const model = sanitizeModel(entry);
855
+ if (model === void 0 || seen.has(model.id)) return void 0;
856
+ seen.add(model.id);
857
+ models.push(model);
858
+ }
859
+ return {
860
+ at: raw.at,
861
+ models
862
+ };
863
+ }
864
+ /** Read the whole file; missing or unparsable reads as an empty cache. */
865
+ async function readCatalogFile(path) {
866
+ let text;
867
+ try {
868
+ text = await readFile(path, "utf8");
869
+ } catch {
870
+ return {};
871
+ }
872
+ try {
873
+ const parsed = JSON.parse(text);
874
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
875
+ return parsed;
876
+ } catch {
877
+ return {};
878
+ }
879
+ }
880
+ /** Persist the whole file atomically (tmp file + rename). */
881
+ async function writeCatalogFile(store, path) {
882
+ await mkdir(dirname(path), { recursive: true });
883
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
884
+ try {
885
+ await writeFile(tmp, JSON.stringify(store, null, 2));
886
+ await rename(tmp, path);
887
+ } catch (error) {
888
+ await rm(tmp, { force: true });
889
+ throw error;
890
+ }
891
+ }
892
+ /**
893
+ * Build the durable half of one provider's catalog cache over the shared
894
+ * models.json file (concurrent writers are last-writer-wins, acceptable for
895
+ * a cache).
896
+ * @param provider - the provider route keying the file entry.
897
+ * @param path - store file path; defaults to {@link modelsFilePath}.
898
+ * @returns the persistence hooks for {@link ModelCatalogCache}.
899
+ */
900
+ function catalogStore(provider, path = modelsFilePath()) {
901
+ return {
902
+ async load() {
903
+ return sanitizeSnapshot((await readCatalogFile(path))[provider]);
904
+ },
905
+ async save(snapshot) {
906
+ const store = await readCatalogFile(path);
907
+ store[provider] = snapshot;
908
+ await writeCatalogFile(store, path);
909
+ },
910
+ async clear() {
911
+ const store = await readCatalogFile(path);
912
+ if (store[provider] === void 0) return;
913
+ delete store[provider];
914
+ await writeCatalogFile(store, path);
915
+ }
916
+ };
917
+ }
918
+
720
919
  //#endregion
721
920
  //#region src/auth/jwt.ts
722
921
  /** Minimal JWT payload decoding for claims extraction (no signature verification). */
@@ -1325,8 +1524,28 @@ function isCodexPermanentRefreshError(error) {
1325
1524
  return error instanceof OAuthEndpointError && error.oauthCode !== void 0 && PERMANENT_REFRESH_CODES.has(error.oauthCode);
1326
1525
  }
1327
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
+ }
1328
1547
  /** Map one wham/usage window into a {@link UsageWindow}; undefined when unusable. */
1329
- function codexUsageWindow(value, kind) {
1548
+ function codexUsageWindow(value, fallbackKind) {
1330
1549
  if (typeof value !== "object" || value === null) return void 0;
1331
1550
  const window = value;
1332
1551
  if (typeof window.used_percent !== "number" || !Number.isFinite(window.used_percent)) return void 0;
@@ -1334,7 +1553,7 @@ function codexUsageWindow(value, kind) {
1334
1553
  if (typeof window.reset_at === "number" && window.reset_at > 0) resetsAt = window.reset_at * 1e3;
1335
1554
  else if (typeof window.reset_after_seconds === "number" && window.reset_after_seconds > 0) resetsAt = Date.now() + window.reset_after_seconds * 1e3;
1336
1555
  return {
1337
- kind,
1556
+ kind: codexWindowKind(window, fallbackKind),
1338
1557
  usedPercent: window.used_percent,
1339
1558
  ...resetsAt === void 0 ? {} : { resetsAt }
1340
1559
  };
@@ -1342,8 +1561,11 @@ function codexUsageWindow(value, kind) {
1342
1561
  /**
1343
1562
  * Fetch the codex subscription usage from the ChatGPT backend wham/usage
1344
1563
  * endpoint (the source of the codex CLI `/status` rate-limit lines). The
1345
- * primary window is the rolling session (5-hour) lane, the secondary window
1346
- * 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.
1347
1569
  * @param session - the stored session (used as-is; never refreshed here).
1348
1570
  * @param fetchFn - fetch implementation (injectable for tests).
1349
1571
  * @param signal - caller cancellation from the RPC transport.
@@ -1430,10 +1652,15 @@ async function fetchCodexModels(session, fetchFn = fetch) {
1430
1652
  }
1431
1653
  /** Codex wire adapter: one instance serves the `codex` provider route. */
1432
1654
  var CodexAdapter = class extends LlmAdapter {
1433
- catalog = new ModelCatalogCache();
1655
+ catalog;
1434
1656
  constructor(options) {
1435
1657
  super();
1436
1658
  this.options = options;
1659
+ this.catalog = new ModelCatalogCache(options.catalogStore);
1660
+ }
1661
+ /** Discovery fetcher: resolves the session through the refresh-aware path. */
1662
+ async fetchCatalog() {
1663
+ return fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn);
1437
1664
  }
1438
1665
  providerInfo(provider) {
1439
1666
  return {
@@ -1453,7 +1680,7 @@ var CodexAdapter = class extends LlmAdapter {
1453
1680
  if (await this.options.tokens.peek() === void 0) return [];
1454
1681
  if (!this.options.discovery) return this.staticModels(provider);
1455
1682
  try {
1456
- return (await this.catalog.get(async () => fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn))).map((model) => ({
1683
+ return (await this.catalog.get(() => this.fetchCatalog())).map((model) => ({
1457
1684
  provider,
1458
1685
  id: model.id,
1459
1686
  name: model.name,
@@ -1467,10 +1694,21 @@ var CodexAdapter = class extends LlmAdapter {
1467
1694
  return this.staticModels(provider);
1468
1695
  }
1469
1696
  }
1470
- resolveModel(provider, model) {
1471
- const discovered = this.options.discovery ? this.catalog.cached()?.find((entry) => entry.id === model) : void 0;
1697
+ /**
1698
+ * The discovered entry for one model. Resolved through the cache's
1699
+ * stale-while-revalidate path so capability metadata stays stable across a
1700
+ * long conversation: a discovered-only effort (one missing from the static
1701
+ * CODEX_EFFORTS list) selected by the user must not vanish — and fail the
1702
+ * call — just because the TTL lapsed mid-turn.
1703
+ */
1704
+ async discovered(model) {
1705
+ if (!this.options.discovery) return void 0;
1706
+ return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
1707
+ }
1708
+ async resolveModel(provider, model) {
1709
+ const discovered = await this.discovered(model);
1472
1710
  const configured = this.options.models.find((entry) => entry.id === model);
1473
- return Promise.resolve({
1711
+ return {
1474
1712
  provider,
1475
1713
  id: model,
1476
1714
  name: discovered?.name ?? configured?.name ?? model,
@@ -1482,7 +1720,7 @@ var CodexAdapter = class extends LlmAdapter {
1482
1720
  efforts: CODEX_EFFORTS,
1483
1721
  defaultEffort: CODEX_DEFAULT_EFFORT
1484
1722
  }
1485
- });
1723
+ };
1486
1724
  }
1487
1725
  async *stream(options) {
1488
1726
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
@@ -2538,10 +2776,15 @@ async function fetchGrokModels(session, fetchFn = fetch, onWarn) {
2538
2776
  }
2539
2777
  /** Grok wire adapter: one instance serves the `grok` provider route. */
2540
2778
  var GrokAdapter = class extends LlmAdapter {
2541
- catalog = new ModelCatalogCache();
2779
+ catalog;
2542
2780
  constructor(options) {
2543
2781
  super();
2544
2782
  this.options = options;
2783
+ this.catalog = new ModelCatalogCache(options.catalogStore);
2784
+ }
2785
+ /** Discovery fetcher: resolves the session through the refresh-aware path. */
2786
+ async fetchCatalog() {
2787
+ return fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn, this.options.onWarn);
2545
2788
  }
2546
2789
  providerInfo(provider) {
2547
2790
  return {
@@ -2561,7 +2804,7 @@ var GrokAdapter = class extends LlmAdapter {
2561
2804
  if (await this.options.tokens.peek() === void 0) return [];
2562
2805
  if (!this.options.discovery) return this.staticModels(provider);
2563
2806
  try {
2564
- return (await this.catalog.get(async () => fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn, this.options.onWarn))).map((model) => ({
2807
+ return (await this.catalog.get(() => this.fetchCatalog())).map((model) => ({
2565
2808
  provider,
2566
2809
  id: model.id,
2567
2810
  name: model.name,
@@ -2575,10 +2818,22 @@ var GrokAdapter = class extends LlmAdapter {
2575
2818
  return this.staticModels(provider);
2576
2819
  }
2577
2820
  }
2578
- resolveModel(provider, model) {
2579
- const discovered = this.options.discovery ? this.catalog.cached()?.find((entry) => entry.id === model) : void 0;
2821
+ /**
2822
+ * The discovered entry for one model. Resolved through the cache's
2823
+ * stale-while-revalidate path: capability metadata must stay stable across
2824
+ * a long conversation — a session that selected a reasoning effort calls
2825
+ * this on EVERY step, and forgetting the efforts just because the TTL
2826
+ * lapsed mid-turn would fail the call with UNSUPPORTED_REASONING_EFFORT
2827
+ * before provider I/O.
2828
+ */
2829
+ async discovered(model) {
2830
+ if (!this.options.discovery) return void 0;
2831
+ return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
2832
+ }
2833
+ async resolveModel(provider, model) {
2834
+ const discovered = await this.discovered(model);
2580
2835
  const configured = this.options.models.find((entry) => entry.id === model);
2581
- return Promise.resolve({
2836
+ return {
2582
2837
  provider,
2583
2838
  id: model,
2584
2839
  name: discovered?.name ?? configured?.name ?? model,
@@ -2587,7 +2842,7 @@ var GrokAdapter = class extends LlmAdapter {
2587
2842
  context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? GROK_CONTEXT_WINDOW },
2588
2843
  defaultMaxTokens: configured?.maxTokens ?? GROK_DEFAULT_MAX_TOKENS,
2589
2844
  ...discovered?.reasoning === void 0 ? {} : { reasoning: discovered.reasoning }
2590
- });
2845
+ };
2591
2846
  }
2592
2847
  async *stream(options) {
2593
2848
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
@@ -2675,7 +2930,7 @@ function normalizeHandles(value, field) {
2675
2930
  if (handles.length > MAX_HANDLES) throw new Error(`x_search: ${field} supports at most ${MAX_HANDLES} handles`);
2676
2931
  return handles;
2677
2932
  }
2678
- function isRecord(value) {
2933
+ function isRecord$1(value) {
2679
2934
  return typeof value === "object" && value !== null && !Array.isArray(value);
2680
2935
  }
2681
2936
  /**
@@ -2684,7 +2939,7 @@ function isRecord(value) {
2684
2939
  * top-level `citations` and inline `url_citation` annotations for sources.
2685
2940
  */
2686
2941
  function parseXSearchResponse(payload) {
2687
- const body = isRecord(payload) ? payload : {};
2942
+ const body = isRecord$1(payload) ? payload : {};
2688
2943
  let answer = typeof body.output_text === "string" ? body.output_text.trim() : "";
2689
2944
  const citations = [];
2690
2945
  const push = (url) => {
@@ -2693,12 +2948,12 @@ function parseXSearchResponse(payload) {
2693
2948
  if (Array.isArray(body.citations)) for (const citation of body.citations) push(citation);
2694
2949
  const parts = [];
2695
2950
  if (Array.isArray(body.output)) for (const item of body.output) {
2696
- if (!isRecord(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
2951
+ if (!isRecord$1(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
2697
2952
  for (const part of item.content) {
2698
- if (!isRecord(part)) continue;
2953
+ if (!isRecord$1(part)) continue;
2699
2954
  if ((part.type === "output_text" || part.type === "text") && typeof part.text === "string" && part.text.trim().length > 0) parts.push(part.text.trim());
2700
2955
  if (Array.isArray(part.annotations)) {
2701
- 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);
2702
2957
  }
2703
2958
  }
2704
2959
  }
@@ -2709,7 +2964,7 @@ function parseXSearchResponse(payload) {
2709
2964
  };
2710
2965
  }
2711
2966
  /** Bound a call-card title's query. */
2712
- function truncate$1(text, max = 60) {
2967
+ function truncate$2(text, max = 60) {
2713
2968
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
2714
2969
  }
2715
2970
  /**
@@ -2781,11 +3036,11 @@ function createXSearchTool(options) {
2781
3036
  },
2782
3037
  presentCall: (args) => ({
2783
3038
  card: "generic",
2784
- title: `x_search: ${truncate$1(args.query)}`,
3039
+ title: `x_search: ${truncate$2(args.query)}`,
2785
3040
  kind: "search"
2786
3041
  }),
2787
3042
  presentResult: (_args, result) => {
2788
- if (result.isError || !isRecord(result.meta)) return void 0;
3043
+ if (result.isError || !isRecord$1(result.meta)) return void 0;
2789
3044
  return {
2790
3045
  card: "web",
2791
3046
  kind: "search",
@@ -2870,7 +3125,7 @@ function imageFileName(index) {
2870
3125
  return `image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}-${index}.png`;
2871
3126
  }
2872
3127
  /** Bound a call-card title's prompt. */
2873
- function truncate(text, max = 60) {
3128
+ function truncate$1(text, max = 60) {
2874
3129
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
2875
3130
  }
2876
3131
  /**
@@ -3006,7 +3261,7 @@ function createImageGenerateTool(options) {
3006
3261
  },
3007
3262
  presentCall: (args) => ({
3008
3263
  card: "generic",
3009
- title: `image_generate: ${truncate(args.prompt)}`
3264
+ title: `image_generate: ${truncate$1(args.prompt)}`
3010
3265
  }),
3011
3266
  presentResult: (_args, result) => ({
3012
3267
  card: "generic",
@@ -3073,6 +3328,249 @@ function createImageGenerateTool(options) {
3073
3328
  });
3074
3329
  }
3075
3330
 
3331
+ //#endregion
3332
+ //#region src/tools/video-generate.ts
3333
+ /** Endpoint the generation request is posted to. */
3334
+ const VIDEO_GENERATE_URL = "https://api.x.ai/v1/videos/generations";
3335
+ /** The video model the grok subscription endpoint serves. */
3336
+ const VIDEO_GENERATE_MODEL = "grok-imagine-video-1.5";
3337
+ /** Polling endpoint for one generation request. */
3338
+ function videoStatusUrl(requestId) {
3339
+ return `https://api.x.ai/v1/videos/${encodeURIComponent(requestId)}`;
3340
+ }
3341
+ /** Default delay between two status polls. */
3342
+ const DEFAULT_POLL_INTERVAL_MS = 3e3;
3343
+ /** Default overall deadline for one generation (submit → done). */
3344
+ const DEFAULT_MAX_WAIT_MS = 10 * 6e4;
3345
+ /** xAI's supported clip length range in seconds. */
3346
+ const DURATION_RANGE = {
3347
+ min: 1,
3348
+ max: 15
3349
+ };
3350
+ /**
3351
+ * Assemble the request body from tool arguments (hand-checks the non-empty
3352
+ * prompt and the duration range the schema DSL cannot express).
3353
+ */
3354
+ function buildVideoGenerateBody(args) {
3355
+ const prompt = args.prompt.trim();
3356
+ if (prompt.length === 0) throw new Error("video_generate: prompt must be a non-empty string");
3357
+ 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`);
3358
+ const imageUrl = args.image_url?.trim();
3359
+ return {
3360
+ prompt,
3361
+ model: VIDEO_GENERATE_MODEL,
3362
+ ...args.duration === void 0 ? {} : { duration: args.duration },
3363
+ ...args.aspect_ratio === void 0 ? {} : { aspect_ratio: args.aspect_ratio },
3364
+ ...args.resolution === void 0 ? {} : { resolution: args.resolution },
3365
+ ...imageUrl === void 0 || imageUrl.length === 0 ? {} : { image: { url: imageUrl } }
3366
+ };
3367
+ }
3368
+ function isRecord(value) {
3369
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3370
+ }
3371
+ /**
3372
+ * Extract the request id from the submit response. Throws when the payload
3373
+ * carries none.
3374
+ */
3375
+ function parseVideoStartResponse(payload) {
3376
+ const body = isRecord(payload) ? payload : {};
3377
+ if (typeof body.request_id !== "string" || body.request_id.length === 0) throw new Error("video_generate: the response carried no request_id");
3378
+ return body.request_id;
3379
+ }
3380
+ /**
3381
+ * Decode one poll response. A `done` payload without a video URL and an
3382
+ * unrecognized status both throw (the poll loop cannot make progress on
3383
+ * either).
3384
+ */
3385
+ function parseVideoStatusResponse(payload) {
3386
+ const body = isRecord(payload) ? payload : {};
3387
+ switch (body.status) {
3388
+ case "pending": return { status: "pending" };
3389
+ case "done": {
3390
+ const video = isRecord(body.video) ? body.video : {};
3391
+ if (typeof video.url !== "string" || video.url.length === 0) throw new Error("video_generate: the completed response carried no video URL");
3392
+ return {
3393
+ status: "done",
3394
+ url: video.url,
3395
+ ...typeof video.duration === "number" ? { duration: video.duration } : {}
3396
+ };
3397
+ }
3398
+ case "failed":
3399
+ case "expired": {
3400
+ const error = isRecord(body.error) ? body.error : {};
3401
+ const detail = typeof error.message === "string" && error.message.length > 0 ? error.message : typeof body.error === "string" && body.error.length > 0 ? body.error : void 0;
3402
+ return {
3403
+ status: body.status,
3404
+ ...detail === void 0 ? {} : { detail }
3405
+ };
3406
+ }
3407
+ default: throw new Error(`video_generate: unexpected status ${JSON.stringify(body.status)}`);
3408
+ }
3409
+ }
3410
+ /** Directory the downloaded MP4 files are written to. */
3411
+ function videosDirectory() {
3412
+ return dshHomePath("plugins", "subscriptions", "videos");
3413
+ }
3414
+ /** Timestamped, collision-safe file name for one generated video. */
3415
+ function videoFileName() {
3416
+ return `video-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}.mp4`;
3417
+ }
3418
+ /** Bound a call-card title's prompt. */
3419
+ function truncate(text, max = 60) {
3420
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
3421
+ }
3422
+ /** Abort-aware sleep between two polls. */
3423
+ function sleep(ms, signal) {
3424
+ if (ms <= 0) return Promise.resolve();
3425
+ return new Promise((resolve, reject) => {
3426
+ const onAbort = () => {
3427
+ clearTimeout(timer);
3428
+ reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("video_generate: aborted"));
3429
+ };
3430
+ const timer = setTimeout(() => {
3431
+ signal.removeEventListener("abort", onAbort);
3432
+ resolve();
3433
+ }, ms);
3434
+ if (signal.aborted) {
3435
+ onAbort();
3436
+ return;
3437
+ }
3438
+ signal.addEventListener("abort", onAbort, { once: true });
3439
+ });
3440
+ }
3441
+ /**
3442
+ * Build the `video_generate` tool definition.
3443
+ * @param options - grok session source, fetch implementation, and video directory.
3444
+ * @returns the tool to register on `ctx.tools`.
3445
+ */
3446
+ function createVideoGenerateTool(options) {
3447
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
3448
+ const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
3449
+ return defineTool({
3450
+ name: "video_generate",
3451
+ 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).`,
3452
+ parameters: {
3453
+ prompt: {
3454
+ type: "string",
3455
+ required: true,
3456
+ description: "What the video should show."
3457
+ },
3458
+ duration: {
3459
+ type: "integer",
3460
+ description: "Clip length in seconds (1-15); omit for the provider default."
3461
+ },
3462
+ aspect_ratio: {
3463
+ type: "string",
3464
+ enum: [
3465
+ "16:9",
3466
+ "9:16",
3467
+ "1:1",
3468
+ "4:3",
3469
+ "3:4",
3470
+ "3:2",
3471
+ "2:3"
3472
+ ],
3473
+ description: "Output aspect ratio; omit for the provider default (16:9)."
3474
+ },
3475
+ resolution: {
3476
+ type: "string",
3477
+ enum: [
3478
+ "480p",
3479
+ "720p",
3480
+ "1080p"
3481
+ ],
3482
+ description: "Output resolution; omit for the provider default (480p). Higher is slower."
3483
+ },
3484
+ image_url: {
3485
+ type: "string",
3486
+ 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."
3487
+ }
3488
+ },
3489
+ output: {
3490
+ schema: {
3491
+ type: "object",
3492
+ properties: {
3493
+ path: {
3494
+ type: "string",
3495
+ required: true
3496
+ },
3497
+ url: {
3498
+ type: "string",
3499
+ required: true
3500
+ },
3501
+ duration: { type: "number" }
3502
+ },
3503
+ additionalProperties: false
3504
+ },
3505
+ render: (_args, value) => [{
3506
+ type: "text",
3507
+ text: `Saved video to ${value.path}` + (value.duration === void 0 ? "" : ` (${String(value.duration)}s)`) + `\nTemporary provider URL (expires soon): ${value.url}`
3508
+ }],
3509
+ presentationMeta: (_args, value) => ({
3510
+ fileName: basename(value.path),
3511
+ ...value.duration === void 0 ? {} : { duration: value.duration }
3512
+ })
3513
+ },
3514
+ presentCall: (args) => ({
3515
+ card: "generic",
3516
+ title: `video_generate: ${truncate(args.prompt)}`
3517
+ }),
3518
+ async execute(args, exec) {
3519
+ const body = buildVideoGenerateBody(args);
3520
+ const session = await options.tokens.session();
3521
+ const fetchFn = options.fetchFn ?? fetch;
3522
+ const headers = {
3523
+ "authorization": `Bearer ${session.accessToken}`,
3524
+ "accept": "application/json"
3525
+ };
3526
+ const submit = await fetchFn(VIDEO_GENERATE_URL, {
3527
+ method: "POST",
3528
+ headers: {
3529
+ ...headers,
3530
+ "content-type": "application/json"
3531
+ },
3532
+ body: JSON.stringify(body),
3533
+ signal: exec.signal
3534
+ });
3535
+ if (!submit.ok) throw await httpLlmError(submit, "video_generate");
3536
+ const requestId = parseVideoStartResponse(await submit.json());
3537
+ const deadline = Date.now() + maxWaitMs;
3538
+ let done;
3539
+ for (;;) {
3540
+ await sleep(pollIntervalMs, exec.signal);
3541
+ const poll = await fetchFn(videoStatusUrl(requestId), {
3542
+ method: "GET",
3543
+ headers,
3544
+ signal: exec.signal
3545
+ });
3546
+ if (!poll.ok) throw await httpLlmError(poll, "video_generate");
3547
+ const status = parseVideoStatusResponse(await poll.json());
3548
+ if (status.status === "done") {
3549
+ done = status;
3550
+ break;
3551
+ }
3552
+ if (status.status === "failed" || status.status === "expired") throw new Error(`video_generate: generation ${status.status} (request ${requestId})` + (status.detail === void 0 ? "" : `: ${status.detail}`));
3553
+ if (Date.now() >= deadline) throw new Error(`video_generate: timed out after ${String(maxWaitMs)}ms waiting for request ${requestId}`);
3554
+ }
3555
+ const download = await fetchFn(done.url, {
3556
+ method: "GET",
3557
+ signal: exec.signal
3558
+ });
3559
+ if (!download.ok) throw await httpLlmError(download, "video_generate download");
3560
+ const data = Buffer.from(await download.arrayBuffer());
3561
+ const directory = options.videosDir ?? videosDirectory();
3562
+ await mkdir(directory, { recursive: true });
3563
+ const path = join(directory, videoFileName());
3564
+ await writeFile(path, data);
3565
+ return {
3566
+ path,
3567
+ url: done.url,
3568
+ ...done.duration === void 0 ? {} : { duration: done.duration }
3569
+ };
3570
+ }
3571
+ });
3572
+ }
3573
+
3076
3574
  //#endregion
3077
3575
  //#region src/index.ts
3078
3576
  const name = "dsh-plugin-subscriptions";
@@ -3202,6 +3700,12 @@ var SubscriptionsAuthController = class {
3202
3700
  dataBase64: Buffer.from(stored.data).toString("base64")
3203
3701
  };
3204
3702
  }
3703
+ async readVideo(name$1, signal) {
3704
+ return {
3705
+ mediaType: "video/mp4",
3706
+ dataBase64: (await readFile(join(videosDirectory(), name$1), { signal })).toString("base64")
3707
+ };
3708
+ }
3205
3709
  async status(provider) {
3206
3710
  const session = await getSession(provider);
3207
3711
  const account = accountOf(provider, session);
@@ -3303,7 +3807,8 @@ function apply(ctx, config) {
3303
3807
  tokens,
3304
3808
  discovery: !overridden.has("codex"),
3305
3809
  onWarn,
3306
- resolveAttachments
3810
+ resolveAttachments,
3811
+ catalogStore: catalogStore("codex")
3307
3812
  })));
3308
3813
  break;
3309
3814
  }
@@ -3350,14 +3855,18 @@ function apply(ctx, config) {
3350
3855
  tokens,
3351
3856
  discovery: !overridden.has("grok"),
3352
3857
  onWarn,
3353
- resolveAttachments
3858
+ resolveAttachments,
3859
+ catalogStore: catalogStore("grok")
3354
3860
  })));
3355
3861
  break;
3356
3862
  }
3357
3863
  }
3358
3864
  registerAuthRpc(ctx, new SubscriptionsAuthController(flows, authChanged, resolveAttachments, usageFetchers));
3359
3865
  ctx.inject(["tools"], (toolsCtx) => {
3360
- if (grokTokens !== void 0) toolsCtx.tools.register(createXSearchTool({ tokens: grokTokens }));
3866
+ if (grokTokens !== void 0) {
3867
+ toolsCtx.tools.register(createXSearchTool({ tokens: grokTokens }));
3868
+ toolsCtx.tools.register(createVideoGenerateTool({ tokens: grokTokens }));
3869
+ }
3361
3870
  if (codexTokens !== void 0) toolsCtx.tools.register(createImageGenerateTool({
3362
3871
  tokens: codexTokens,
3363
3872
  resolveAttachments,