@contenthero/sdk 0.3.2 → 0.3.3

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/dist/client.js CHANGED
@@ -6,13 +6,20 @@
6
6
  * SDK offers both a fire-and-forget `generate` and a `generateAndWait` that
7
7
  * polls to a terminal state for you.
8
8
  */
9
- import { errorFromResponse, GenerationFailedError, GenerationTimeoutError } from './errors.js';
9
+ import { errorFromResponse, GenerationFailedError, GenerationInterruptedError, GenerationTimeoutError, } from './errors.js';
10
10
  const DEFAULT_BASE_URL = 'https://app.contenthero.ai';
11
11
  const TERMINAL = new Set(['completed', 'failed']);
12
12
  export class ContentHero {
13
13
  apiKey;
14
14
  baseUrl;
15
15
  fetchImpl;
16
+ /** Per-project last-touched timestamp (ms). Presence is lit SERVER-SIDE (every project-scoped route broadcasts
17
+ * the badge), so this no longer drives per-call pings; it records which projects this client operated on so
18
+ * releaseProjectActivity / flushRelease can clear their badges promptly on exit, and debounces any explicit
19
+ * touchProjectActivity ping. */
20
+ activityPingedAt = new Map();
21
+ /** Min gap between explicit presence pings for the same project, so a burst does not spam. */
22
+ static ACTIVITY_DEBOUNCE_MS = 10_000;
16
23
  constructor(options = {}) {
17
24
  const apiKey = options.apiKey ?? readEnv('CONTENTHERO_API_KEY');
18
25
  if (!apiKey) {
@@ -26,6 +33,43 @@ export class ContentHero {
26
33
  this.baseUrl = (options.baseUrl ?? readEnv('CONTENTHERO_BASE_URL') ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
27
34
  this.fetchImpl = fetchImpl;
28
35
  }
36
+ /**
37
+ * Explicitly signal PRESENCE for a project. Presence is normally lit SERVER-SIDE now (every project-scoped API
38
+ * route broadcasts the "Editing via MCP/CLI" badge for the resolved project), so the SDK's own methods no longer
39
+ * each call this. It remains for an external consumer that wants to signal presence ahead of working on a
40
+ * project, and it records the project for prompt release on exit. Debounced + fire-and-forget: never throws,
41
+ * never blocks, no-op without a projectId.
42
+ */
43
+ touchProjectActivity(projectId) {
44
+ if (!projectId)
45
+ return;
46
+ const now = Date.now();
47
+ const last = this.activityPingedAt.get(projectId) ?? 0;
48
+ if (now - last < ContentHero.ACTIVITY_DEBOUNCE_MS)
49
+ return;
50
+ this.activityPingedAt.set(projectId, now);
51
+ void this.request('POST', '/api/v1/editor/activity', { projectId }).catch(() => { });
52
+ }
53
+ /**
54
+ * Explicitly RELEASE presence so the badge clears promptly (no TTL lingering). Use in a `finally` when a
55
+ * short-lived consumer (the CLI) finishes a command. Fire-and-forget.
56
+ */
57
+ releaseProjectActivity(projectId) {
58
+ if (!projectId)
59
+ return;
60
+ this.activityPingedAt.delete(projectId);
61
+ void this.request('POST', '/api/v1/editor/activity', { projectId, release: true }).catch(() => { });
62
+ }
63
+ /**
64
+ * AWAIT a presence release for every project this client pinged. The CLI calls this before the process exits
65
+ * so the badge clears immediately (a short-lived process cannot rely on the sliding TTL). Best-effort:
66
+ * resolves even if the releases fail.
67
+ */
68
+ async flushRelease() {
69
+ const ids = [...this.activityPingedAt.keys()];
70
+ this.activityPingedAt.clear();
71
+ await Promise.all(ids.map((projectId) => this.request('POST', '/api/v1/editor/activity', { projectId, release: true }).catch(() => { })));
72
+ }
29
73
  /**
30
74
  * Submit a generation. Returns immediately. For image/video the result is
31
75
  * `status: 'processing'` (poll with `getGeneration` or use `generateAndWait`);
@@ -46,7 +90,10 @@ export class ContentHero {
46
90
  */
47
91
  async generateAndWait(request, options = {}) {
48
92
  const submitted = await this.generate(request);
49
- return this.waitForGeneration(submitted.outputId, options);
93
+ const gen = await this.#waitAfterSubmit(submitted.outputId, options);
94
+ // Carry the placement outcome (known at submit time) through to the completed record so the caller learns
95
+ // where the asset landed + its id for chaining, without a separate lookup.
96
+ return submitted.placement ? { ...gen, placement: submitted.placement } : gen;
50
97
  }
51
98
  /**
52
99
  * Submit a Reference Board: a dense multi-panel reference sheet built from a
@@ -65,7 +112,26 @@ export class ContentHero {
65
112
  */
66
113
  async generateBoardAndWait(request, options = {}) {
67
114
  const submitted = await this.generateBoard(request);
68
- return this.waitForGeneration(submitted.outputId, options);
115
+ return this.#waitAfterSubmit(submitted.outputId, options);
116
+ }
117
+ /**
118
+ * Poll a job that has ALREADY been submitted, converting any non-terminal failure into
119
+ * an error that still carries the outputId.
120
+ *
121
+ * Once the POST succeeds the job is running and charged, so the outputId is the only
122
+ * thing standing between a transient poll failure and a duplicate generation: a caller
123
+ * that loses it has no way to resume and will almost certainly resubmit. A genuine
124
+ * `GenerationFailedError` is terminal and passes through untouched.
125
+ */
126
+ async #waitAfterSubmit(outputId, options) {
127
+ try {
128
+ return await this.waitForGeneration(outputId, options);
129
+ }
130
+ catch (err) {
131
+ if (err instanceof GenerationFailedError || err instanceof GenerationTimeoutError)
132
+ throw err;
133
+ throw new GenerationInterruptedError(outputId, err);
134
+ }
69
135
  }
70
136
  /**
71
137
  * Estimate the credit cost of a generation without running it (the get_cost
@@ -92,7 +158,10 @@ export class ContentHero {
92
158
  const deadline = Date.now() + timeoutMs;
93
159
  while (true) {
94
160
  const generation = await this.getGeneration(outputId);
95
- if (generation.status === 'completed')
161
+ // Terminal only when SETTLED: a placement-bearing generation is not "done" for a caller until its swap /
162
+ // cutout side-effect has landed (see Generation.settled). `settled !== false` keeps older servers (which
163
+ // omit the field) working as before, and a no-placement output is settled the moment it completes.
164
+ if (generation.status === 'completed' && generation.settled !== false)
96
165
  return generation;
97
166
  if (generation.status === 'failed') {
98
167
  throw new GenerationFailedError(generation.outputId, generation.error ?? 'Generation failed');
@@ -114,6 +183,25 @@ export class ContentHero {
114
183
  async transcribe(request) {
115
184
  return this.request('POST', '/api/v1/studio/transcribe', request);
116
185
  }
186
+ /**
187
+ * Transform existing audio with an audio-processing model. Two shapes:
188
+ *
189
+ * FILE mode (`sourceUrl`): process a standalone file into a new library asset. Voice isolation is
190
+ * synchronous and the processed URL comes back inline on `outputUrls`; enhancement is asynchronous and
191
+ * returns an `outputId` to poll.
192
+ *
193
+ * IN-PLACE mode (`projectId` + `clipIds` / `enhanceClips`): enhance the audio OF EXISTING CLIPS on a
194
+ * timeline. Returns one job per SOURCE on `outputs`, because the vendor estimates a noise profile per
195
+ * production, so a source's clips are concatenated and enhanced together while separate recordings stay
196
+ * separate jobs.
197
+ */
198
+ async editAudio(request) {
199
+ return this.request('POST', '/api/v1/studio/audio/edit', request);
200
+ }
201
+ /** Cost preview for `editAudio` (nothing runs, nothing is charged). */
202
+ async estimateEditAudioCost(request) {
203
+ return this.request('POST', '/api/v1/studio/audio/edit', { ...request, getCost: true });
204
+ }
117
205
  /** List the account's avatars (the list half of the list+get pair). */
118
206
  async listAvatars() {
119
207
  const data = await this.request('GET', '/api/v1/avatars');
@@ -124,8 +212,12 @@ export class ContentHero {
124
212
  return this.request('GET', `/api/v1/avatars/${encodeURIComponent(avatarId)}`);
125
213
  }
126
214
  /** List the account's saved voices (the list half of the list+get pair). */
127
- async listVoices() {
128
- const data = await this.request('GET', '/api/v1/voices');
215
+ async listVoices(options = {}) {
216
+ const q = new URLSearchParams();
217
+ if (options.favorited)
218
+ q.set('favorited', 'true');
219
+ const qs = q.toString();
220
+ const data = await this.request('GET', `/api/v1/voices${qs ? `?${qs}` : ''}`);
129
221
  return data.voices;
130
222
  }
131
223
  /** Get one voice's detail (the get half). Throws NotFoundError if absent. */
@@ -133,8 +225,14 @@ export class ContentHero {
133
225
  return this.request('GET', `/api/v1/voices/${encodeURIComponent(voiceId)}`);
134
226
  }
135
227
  /** List the account's brand kits (the list half of the list+get pair). */
136
- async listBrandKits() {
137
- const data = await this.request('GET', '/api/v1/brand-kits');
228
+ async listBrandKits(options = {}) {
229
+ const q = new URLSearchParams();
230
+ if (options.favorited)
231
+ q.set('favorited', 'true');
232
+ if (options.archived)
233
+ q.set('archived', 'true');
234
+ const qs = q.toString();
235
+ const data = await this.request('GET', `/api/v1/brand-kits${qs ? `?${qs}` : ''}`);
138
236
  return data.brandKits;
139
237
  }
140
238
  /** Get one brand kit, fully assembled (the get half). Throws NotFoundError if absent. */
@@ -149,11 +247,6 @@ export class ContentHero {
149
247
  async updateBrandKit(brandKitId, input) {
150
248
  return this.request('PATCH', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}`, input);
151
249
  }
152
- /** Archive a brand kit (reversible). Requires the `brandkit:write` scope. */
153
- async archiveBrandKit(brandKitId) {
154
- const data = await this.request('PATCH', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}`, { archive: true });
155
- return data.brandKit;
156
- }
157
250
  /** Add a curated section to a brand kit. Requires the `brandkit:write` scope. */
158
251
  async addBrandKitSection(brandKitId, input) {
159
252
  const data = await this.request('POST', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/sections`, input);
@@ -164,11 +257,6 @@ export class ContentHero {
164
257
  const data = await this.request('PATCH', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/sections/${encodeURIComponent(sectionId)}`, input);
165
258
  return data.section;
166
259
  }
167
- /** Archive a brand-kit section (soft delete, reversible). Requires `brandkit:write`. */
168
- async archiveBrandKitSection(brandKitId, sectionId) {
169
- const data = await this.request('DELETE', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/sections/${encodeURIComponent(sectionId)}`);
170
- return data.section;
171
- }
172
260
  // -------------------------------------------------------------------------
173
261
  // Brand knowledge (a brand kit's knowledge base)
174
262
  // -------------------------------------------------------------------------
@@ -206,9 +294,15 @@ export class ContentHero {
206
294
  async removeBrandKnowledge(brandKitId, knowledgeId) {
207
295
  return this.request('DELETE', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/knowledge/${encodeURIComponent(knowledgeId)}`);
208
296
  }
209
- /** List the account's recent studio outputs (the list half of the list+get pair). */
297
+ /**
298
+ * List the account's recent media (the list half of the list+get pair). `source`
299
+ * selects the library: 'creations' (default, studio outputs) or 'uploads' (the
300
+ * editor Uploads tab).
301
+ */
210
302
  async listMedia(options = {}) {
211
303
  const q = new URLSearchParams();
304
+ if (options.source)
305
+ q.set('source', options.source);
212
306
  if (options.contentType) {
213
307
  const types = Array.isArray(options.contentType) ? options.contentType : [options.contentType];
214
308
  q.set('contentType', types.join(','));
@@ -217,6 +311,10 @@ export class ContentHero {
217
311
  q.set('status', options.status);
218
312
  if (options.kind)
219
313
  q.set('kind', options.kind);
314
+ if (options.favorited)
315
+ q.set('favorited', 'true');
316
+ if (options.archived)
317
+ q.set('archived', 'true');
220
318
  if (options.limit != null)
221
319
  q.set('limit', String(options.limit));
222
320
  if (options.offset != null)
@@ -226,12 +324,71 @@ export class ContentHero {
226
324
  return data.media;
227
325
  }
228
326
  /**
229
- * Get one studio output by id token (the get half). The token may be the full
230
- * output id, its first 8 characters, or either with a `-N` variation suffix
231
- * (1-based). Throws NotFoundError if absent.
327
+ * Get one media item by id token (the get half). `source` selects the library the
328
+ * id belongs to: 'creations' (default, a studio output; token may be the full id,
329
+ * its first 8 characters, or either with a `-N` variation suffix), 'uploads' (an
330
+ * editor Uploads-tab file; full id or short id, no variations), or 'stock' (a used
331
+ * stock item; full id or short id, no variations). Pass the same source the item
332
+ * reported in listMedia. Throws NotFoundError if absent.
232
333
  */
233
- async getMedia(idToken) {
234
- return this.request('GET', `/api/v1/media/${encodeURIComponent(idToken)}`);
334
+ async getMedia(idToken, options = {}) {
335
+ const qs = options.source ? `?source=${encodeURIComponent(options.source)}` : '';
336
+ return this.request('GET', `/api/v1/media/${encodeURIComponent(idToken)}${qs}`);
337
+ }
338
+ /**
339
+ * Semantically search the account's editable media library (creations, uploads, licensed stock, brand assets)
340
+ * by describing the content in natural language. Returns matching assets ranked by relevance, each with a
341
+ * description, tags, and, for videos, the timestamps of the scenes that matched, so a precise moment can be
342
+ * located. Searches only the account's own usable library, never inspiration, published posts, or knowledge.
343
+ */
344
+ async searchMedia(query, options = {}) {
345
+ const q = new URLSearchParams({ query });
346
+ if (options.kinds && options.kinds.length > 0)
347
+ q.set('kinds', options.kinds.join(','));
348
+ if (options.limit != null)
349
+ q.set('limit', String(options.limit));
350
+ const data = await this.request('GET', `/api/v1/media/search?${q.toString()}`);
351
+ return data.results;
352
+ }
353
+ // ─── Library folders (Unified Content Library, Phase D) ────────────────────
354
+ /** List the account's folders (manual + smart, with parent links) plus the built-in derived folders. */
355
+ async listFolders() {
356
+ return this.request('GET', '/api/v1/library/folders');
357
+ }
358
+ /** A folder's contents. `folderId` is a folder id or a derived key (recents|favorites|edits|canvas|posts). */
359
+ async getFolder(folderId) {
360
+ const data = await this.request('GET', `/api/v1/library/folders/${encodeURIComponent(folderId)}`);
361
+ return { folder: data.folder, items: data.items };
362
+ }
363
+ async createFolder(input) {
364
+ const data = await this.request('POST', '/api/v1/library/folders', input);
365
+ return data.folder;
366
+ }
367
+ async updateFolder(folderId, patch) {
368
+ const data = await this.request('PATCH', `/api/v1/library/folders/${encodeURIComponent(folderId)}`, patch);
369
+ return data.folder;
370
+ }
371
+ async deleteFolder(folderId) {
372
+ await this.request('DELETE', `/api/v1/library/folders/${encodeURIComponent(folderId)}`);
373
+ }
374
+ /** File an item into a manual folder (a pointer; no bytes move). */
375
+ async addToFolder(folderId, ref) {
376
+ await this.request('POST', `/api/v1/library/folders/${encodeURIComponent(folderId)}/items`, ref);
377
+ }
378
+ async removeFromFolder(folderId, ref) {
379
+ await this.request('DELETE', `/api/v1/library/folders/${encodeURIComponent(folderId)}/items`, ref);
380
+ }
381
+ /**
382
+ * Resolve a batch of media references to vision-ready URLs + light metadata (the
383
+ * micro drill-in behind get_context's macro screenshot). Each item is a raw
384
+ * `{ url }` or an `{ mediaId, variation? }`; a mediaId with no variation resolves
385
+ * to only the primary variation (siblings listed in `otherVariations`), never a
386
+ * whole generation. Returns one entry per item, in order; a bad item comes back
387
+ * with `ok: false` rather than failing the batch. Max 10 items per call (the SDK
388
+ * returns URLs; the MCP layer is what turns them into image blocks for a model).
389
+ */
390
+ async getMediaBatch(items) {
391
+ return this.request('POST', '/api/v1/media/batch', { items });
235
392
  }
236
393
  // -------------------------------------------------------------------------
237
394
  // Media upload (bring your own file or URL -> first-class media)
@@ -266,11 +423,23 @@ export class ContentHero {
266
423
  contentType: opts.contentType,
267
424
  sizeBytes,
268
425
  });
269
- // The PUT goes to Supabase storage (not our API), so it uses a bare fetch
270
- // with only the file's Content-Type, none of our auth headers.
426
+ // The PUT goes straight to object storage (not our API), so it uses a bare
427
+ // fetch with none of our auth headers.
428
+ //
429
+ // The headers come from the SERVER rather than being assumed here. Storage
430
+ // used to be Supabase, where Content-Type alone is enough; it is moving to
431
+ // R2, where the presigned URL signs the owner in as `x-amz-meta-user_id` and
432
+ // a PUT missing it is rejected with SignatureDoesNotMatch (verified: 403
433
+ // with Content-Type alone, 200 with both). Letting the server say what to
434
+ // send means this client never has to know which store it is talking to,
435
+ // and the migration needs no coordinated release of it.
436
+ //
437
+ // Falls back to Content-Type so an older API that does not return
438
+ // uploadHeaders keeps working, which is what makes the two deployable in
439
+ // either order.
271
440
  const put = await this.fetchImpl(created.uploadUrl, {
272
441
  method: 'PUT',
273
- headers: { 'Content-Type': opts.contentType },
442
+ headers: created.uploadHeaders ?? { 'Content-Type': opts.contentType },
274
443
  body: data,
275
444
  });
276
445
  if (!put.ok) {
@@ -399,10 +568,6 @@ export class ContentHero {
399
568
  const data = await this.request('PATCH', `/api/v1/posts/${encodeURIComponent(postId)}`, input);
400
569
  return data.post;
401
570
  }
402
- /** Archive a post (status -> 'archived'). No hard delete. */
403
- async archivePost(postId) {
404
- return this.updatePost(postId, { status: 'archived' });
405
- }
406
571
  /**
407
572
  * List the account's pipeline stages (sorted), seeding the defaults on first
408
573
  * access. Use this to resolve a stage before placing a post; stages are
@@ -510,6 +675,8 @@ export class ContentHero {
510
675
  q.set('sort_by', options.sortBy);
511
676
  if (options.brandKitId)
512
677
  q.set('brand_kit_id', options.brandKitId);
678
+ if (options.favorited)
679
+ q.set('favorited', 'true');
513
680
  if (options.limit != null)
514
681
  q.set('limit', String(options.limit));
515
682
  if (options.offset != null)
@@ -545,6 +712,281 @@ export class ContentHero {
545
712
  const data = await this.request('GET', `/api/v1/connected-accounts/${encodeURIComponent(accountId)}`);
546
713
  return data.account;
547
714
  }
715
+ // -------------------------------------------------------------------------
716
+ // Favorites & archive (universal set/clear across asset types)
717
+ // -------------------------------------------------------------------------
718
+ /**
719
+ * Mark an asset as favorited. Requires the `favorites:write` scope.
720
+ *
721
+ * Pass `{ assetType, id }` for a top-level asset (post, voice, brand_kit,
722
+ * project, inspiration_content, gallery), or `{ id, variationIndex }` to
723
+ * favorite a single studio output variation slot (id is a studio output id).
724
+ * Idempotent.
725
+ */
726
+ async favorite(input) {
727
+ await this.request('POST', '/api/v1/favorite', input);
728
+ }
729
+ /**
730
+ * Clear the favorite flag on an asset. Requires the `favorites:write` scope.
731
+ * Same target shape as `favorite`. Idempotent.
732
+ */
733
+ async unfavorite(input) {
734
+ await this.request('POST', '/api/v1/unfavorite', input);
735
+ }
736
+ /**
737
+ * Archive an asset. Requires the `favorites:write` scope.
738
+ *
739
+ * Pass `{ assetType, id }` for a top-level asset (post, brand_kit,
740
+ * brand_kit_section, project), or `{ id, variationIndex }` to archive a single
741
+ * studio output variation slot. Archiving a post sets its status to 'archived'.
742
+ * Idempotent.
743
+ */
744
+ async archive(input) {
745
+ await this.request('POST', '/api/v1/archive', input);
746
+ }
747
+ /**
748
+ * Unarchive an asset. Requires the `favorites:write` scope. Same target shape
749
+ * as `archive`. Unarchiving a post restores it to 'draft'. Idempotent.
750
+ */
751
+ async unarchive(input) {
752
+ await this.request('POST', '/api/v1/unarchive', input);
753
+ }
754
+ // -------------------------------------------------------------------------
755
+ // Editor / canvas ops (programmatic parity with the manual UI + in-app agent)
756
+ // -------------------------------------------------------------------------
757
+ /**
758
+ * List the caller's projects (both editor + canvas) as lightweight summaries. Filter by archived /
759
+ * favorited state, by `kind`, or by a title search. Requires the `editor:read` scope.
760
+ */
761
+ async listProjects(input = {}) {
762
+ const q = new URLSearchParams();
763
+ if (input.filter)
764
+ q.set('filter', input.filter);
765
+ if (input.kind)
766
+ q.set('kind', input.kind);
767
+ if (input.search)
768
+ q.set('search', input.search);
769
+ const qs = q.toString();
770
+ const { projects } = await this.request('GET', `/api/v1/projects${qs ? `?${qs}` : ''}`);
771
+ return projects;
772
+ }
773
+ /**
774
+ * Read a single project (metadata + composition `state` + `revision`), to read-before-write. Pass the returned
775
+ * `revision` back as `applyEditorOps`'s `expectedRevision`. By DEFAULT `state` is a lightweight SUMMARY (per-clip
776
+ * / per-layer structure, heavy payloads dropped); pass `detail:'full'` for the complete composition. For a
777
+ * timeline, `fromFrame`/`toFrame` (+ `trackId`) scope the read to the clips overlapping that frame window; for a
778
+ * canvas, `slideId` scopes it to a single slide, and applies to `detail:'full'` too so asking for one slide's
779
+ * detail does not pay for the whole deck. A canvas summary echoes each text layer's `text` (truncated), which is
780
+ * what identifies it. Requires the `editor:read` scope.
781
+ */
782
+ async getProject(projectId, options = {}) {
783
+ const params = new URLSearchParams();
784
+ if (options.includeRenderUrl)
785
+ params.set('includeRenderUrl', 'true');
786
+ if (options.detail === 'full')
787
+ params.set('detail', 'full');
788
+ if (typeof options.fromFrame === 'number')
789
+ params.set('fromFrame', String(options.fromFrame));
790
+ if (typeof options.toFrame === 'number')
791
+ params.set('toFrame', String(options.toFrame));
792
+ if (options.trackId)
793
+ params.set('trackId', options.trackId);
794
+ if (options.slideId)
795
+ params.set('slideId', options.slideId);
796
+ const qs = params.toString() ? `?${params.toString()}` : '';
797
+ const { project } = await this.request('GET', `/api/v1/projects/${encodeURIComponent(projectId)}${qs}`);
798
+ return project;
799
+ }
800
+ /**
801
+ * Read the LIVE context of what the user is currently viewing in the open app: the active surface + focus +
802
+ * selection, so an agent can operate on "what the user is looking at" like the internal assistant does. Fast
803
+ * and structured by default. Pass `capture: true` to also ping the live tab for a fresh viewport screenshot
804
+ * (returned as a short-lived `snapshotUrl`) when you need the user's screen as shown. To see the COMPOSED
805
+ * OUTPUT itself (the rendered editor frame or canvas slide), pass `render: true` (optionally `frame` for an
806
+ * editor project, or `slideId` / `slideIndex` for canvas); the render returns inline as a data URL, is
807
+ * ephemeral, and does not need a live tab. Returns the most-recent-active session's context plus the full live
808
+ * participant set. Optionally scope to one project. Requires the `context:read` scope.
809
+ */
810
+ async getContext(input = {}) {
811
+ const params = new URLSearchParams();
812
+ if (input.projectId)
813
+ params.set('projectId', input.projectId);
814
+ if (input.capture)
815
+ params.set('capture', 'true');
816
+ if (input.render)
817
+ params.set('render', 'true');
818
+ if (input.mode)
819
+ params.set('mode', input.mode);
820
+ if (typeof input.frame === 'number')
821
+ params.set('frame', String(input.frame));
822
+ if (input.slideId)
823
+ params.set('slideId', input.slideId);
824
+ if (typeof input.slideIndex === 'number')
825
+ params.set('slideIndex', String(input.slideIndex));
826
+ if (typeof input.fromFrame === 'number')
827
+ params.set('fromFrame', String(input.fromFrame));
828
+ if (typeof input.toFrame === 'number')
829
+ params.set('toFrame', String(input.toFrame));
830
+ if (typeof input.count === 'number')
831
+ params.set('count', String(input.count));
832
+ if (typeof input.width === 'number')
833
+ params.set('width', String(input.width));
834
+ // Canonical URI encoding: URLSearchParams renders a space as '+', which is x-www-form-urlencoded, not the
835
+ // RFC-3986 query encoding; emit %20 so the URL is canonical (both decode to a space server-side).
836
+ const query = params.toString().replace(/\+/g, '%20');
837
+ const qs = query ? `?${query}` : '';
838
+ return this.request('GET', `/api/v1/context${qs}`);
839
+ }
840
+ /**
841
+ * Create an async PREVIEW render (ephemeral, never stored). Currently a short low-res COMPOSED VIDEO of an
842
+ * editor range, so you can assess motion, cuts, transitions, and pacing a still cannot show. Returns a job
843
+ * handle; poll it with `getPreview`. Requires the `context:read` scope.
844
+ */
845
+ async createPreview(input) {
846
+ return this.request('POST', '/api/v1/preview', input);
847
+ }
848
+ /**
849
+ * Poll a preview started with `createPreview`. While rendering, returns `progress`; on completion, returns a
850
+ * short-lived signed `url` to the ephemeral output plus the estimated cost.
851
+ */
852
+ async getPreview(job) {
853
+ const params = new URLSearchParams({ renderId: job.renderId, bucketName: job.bucketName });
854
+ return this.request('GET', `/api/v1/preview?${params.toString()}`);
855
+ }
856
+ /**
857
+ * Create a project. All fields optional; the server applies the same defaults as the in-app new-project
858
+ * flow (16:9 landscape, `editor` kind). A new canvas starts with one empty slide; a new editor starts with
859
+ * an empty timeline. Returns the full detail (with its id + starting revision) so you can immediately apply
860
+ * ops. Requires the `editor:write` scope.
861
+ */
862
+ async createProject(input = {}) {
863
+ const { project } = await this.request('POST', '/api/v1/projects', input);
864
+ return project;
865
+ }
866
+ /**
867
+ * Import a PowerPoint / Google Slides file (by URL) or a Canva design (by id) into a NEW canvas project
868
+ * with editable layers, returning the created project's full detail. The Canva source uses the caller's
869
+ * Canva connection (a ConflictError-like 400 with code 'canva_not_connected' if not connected). Structured
870
+ * import can take a while (export + convert + parse). Requires the `editor:write` scope.
871
+ */
872
+ async importProject(input) {
873
+ const { project } = await this.request('POST', '/api/v1/projects/import', input);
874
+ return project;
875
+ }
876
+ /**
877
+ * PERMANENTLY delete a project (irreversible hard delete, distinct from the reversible archive). The
878
+ * `?confirm=true` opt-in is sent for you. To reversibly hide a project instead, use `archive`. Requires
879
+ * the `editor:write` scope.
880
+ */
881
+ async deleteProject(projectId) {
882
+ await this.request('DELETE', `/api/v1/projects/${encodeURIComponent(projectId)}?confirm=true`);
883
+ }
884
+ /**
885
+ * Apply a batch of ops to a project's composition (canvas slides or editor timeline) and persist
886
+ * atomically. The project's `surface` selects the op vocabulary; the ops run through the same reducers the manual
887
+ * UI and in-app agent use. Requires the `editor:write` scope.
888
+ *
889
+ * Optimistic concurrency: pass `expectedRevision` (from `getProject`) to fail with a 409 ConflictError if
890
+ * a concurrent edit landed, instead of clobbering it. Returns the new revision and the per-op results (a
891
+ * bad op is reported, never throws).
892
+ *
893
+ * Each op is given a client-generated `op_id` (uuid) here if it does not already have one, so the op has a
894
+ * stable identity from the point of intent: resending the same batch is idempotent (the server dedupes by
895
+ * op_id), and a live editor sees the edit as an attributed collaborator change keyed by that id. The
896
+ * assigned id is echoed back on each result's `opId`.
897
+ */
898
+ async applyEditorOps(input) {
899
+ const ops = input.ops.map((op) => typeof op.op_id === 'string' && op.op_id ? op : { ...op, op_id: globalThis.crypto.randomUUID() });
900
+ return this.request('POST', '/api/v1/editor/ops', { ...input, ops });
901
+ }
902
+ /**
903
+ * Start an export (render) of a project's saved composition. `mp4` returns a job with status 'rendering'
904
+ * (poll with getExport or use exportProjectAndWait); canvas still/document formats (png/jpg/pdf/pptx) run
905
+ * synchronously and return 'completed' with the outputUrl. Requires the `editor:write` scope.
906
+ */
907
+ async startExport(projectId, input = {}) {
908
+ return this.request('POST', `/api/v1/projects/${encodeURIComponent(projectId)}/export`, input);
909
+ }
910
+ /** Poll an export job by id. Requires the `editor:read` scope. */
911
+ async getExport(exportId) {
912
+ return this.request('GET', `/api/v1/exports/${encodeURIComponent(exportId)}`);
913
+ }
914
+ /** The kind-aware catalog of export formats + their options. Requires the `editor:read` scope. */
915
+ async getExportFormats() {
916
+ return this.request('GET', '/api/v1/export-formats');
917
+ }
918
+ /**
919
+ * Start an export and poll until it completes. Resolves with the completed job (outputUrl set), throws
920
+ * `GenerationFailedError` on failure, or `GenerationTimeoutError` if it does not finish within `timeoutMs`
921
+ * (the server job may still complete; re-poll with getExport).
922
+ */
923
+ async exportProjectAndWait(projectId, input = {}, options = {}) {
924
+ const job = await this.startExport(projectId, input);
925
+ if (job.status === 'completed' || job.status === 'failed') {
926
+ if (job.status === 'failed')
927
+ throw new GenerationFailedError(job.exportId, job.errorMessage ?? 'Export failed');
928
+ return job;
929
+ }
930
+ return this.waitForExport(job.exportId, options);
931
+ }
932
+ /** Poll an export job to a terminal state. */
933
+ async waitForExport(exportId, options = {}) {
934
+ const { pollIntervalMs = 3000, timeoutMs = 600_000, signal } = options;
935
+ const deadline = Date.now() + timeoutMs;
936
+ while (true) {
937
+ const job = await this.getExport(exportId);
938
+ if (job.status === 'completed')
939
+ return job;
940
+ if (job.status === 'failed')
941
+ throw new GenerationFailedError(exportId, job.errorMessage ?? 'Export failed');
942
+ if (Date.now() >= deadline)
943
+ throw new GenerationTimeoutError(exportId);
944
+ await sleep(pollIntervalMs, signal);
945
+ }
946
+ }
947
+ /**
948
+ * The canvas layer-type catalog (types + editable props), so you know what `update_canvas` ops can
949
+ * create/edit. Requires the `editor:read` scope.
950
+ */
951
+ async getLayerTypes() {
952
+ return this.request('GET', '/api/v1/editor/layer-types');
953
+ }
954
+ /**
955
+ * The editor timeline clip + track-type catalog (types + editable props), so you know what
956
+ * `update_timeline` ops can create/edit. Requires the `editor:read` scope.
957
+ */
958
+ async getTimelineTypes() {
959
+ return this.request('GET', '/api/v1/editor/timeline-types');
960
+ }
961
+ /**
962
+ * Read an editor project's transcript mapped to its timeline clips. Returns one segment per transcribable
963
+ * primary-track clip, in timeline order, carrying the words spoken within it plus its current enabled/disabled
964
+ * state, so you can read what is said, see which parts are already cut, and target exact clipIds with
965
+ * update_timeline (disable_ranges / set_disabled / delete_ranges). Scope with `search` (substring) or
966
+ * `startMs`/`endMs` (source-media time) to fetch only the part you need. Pass `granularity: 'word'` for
967
+ * word-level timing (with absolute timeline frames), per-word confidence + speaker, derived silence gaps, and
968
+ * non-speech audio events. `paceThresholdMs` (minimum pause to report as silence) and `paddingStartMs` /
969
+ * `paddingEndMs` (breathing room kept around speech; negative tightens) tune the silence detection and default
970
+ * to the project's saved pace/padding. Requires the `editor:read` scope.
971
+ */
972
+ async getTranscript(projectId, options = {}) {
973
+ const params = new URLSearchParams({ projectId });
974
+ if (options.search)
975
+ params.set('search', options.search);
976
+ if (options.startMs !== undefined)
977
+ params.set('startMs', String(options.startMs));
978
+ if (options.endMs !== undefined)
979
+ params.set('endMs', String(options.endMs));
980
+ if (options.granularity)
981
+ params.set('granularity', options.granularity);
982
+ if (options.paceThresholdMs !== undefined)
983
+ params.set('paceThresholdMs', String(options.paceThresholdMs));
984
+ if (options.paddingStartMs !== undefined)
985
+ params.set('paddingStartMs', String(options.paddingStartMs));
986
+ if (options.paddingEndMs !== undefined)
987
+ params.set('paddingEndMs', String(options.paddingEndMs));
988
+ return this.request('GET', `/api/v1/editor/transcript?${params.toString()}`);
989
+ }
548
990
  /** Issue an authenticated request and map non-2xx responses to typed errors. */
549
991
  async request(method, path, body) {
550
992
  const headers = {
@@ -553,6 +995,14 @@ export class ContentHero {
553
995
  };
554
996
  if (body !== undefined)
555
997
  headers['Content-Type'] = 'application/json';
998
+ // Release tracking (NOT a presence ping): presence is lit server-side, but the CLI still needs to clear its
999
+ // badge promptly on exit, so record any project this client mutates by id. Local only, no extra request; the
1000
+ // activity endpoint manages its own lease and is skipped.
1001
+ if (body && typeof body === 'object' && !Array.isArray(body) && path !== '/api/v1/editor/activity') {
1002
+ const pid = body.projectId;
1003
+ if (typeof pid === 'string' && pid)
1004
+ this.activityPingedAt.set(pid, Date.now());
1005
+ }
556
1006
  const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
557
1007
  method,
558
1008
  headers,