@contenthero/sdk 0.3.2 → 0.3.4

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,50 @@ 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}` : ''}`);
236
+ return data.brandKits;
237
+ }
238
+ /**
239
+ * Create a brand kit. Requires the `brandkit:write` scope.
240
+ *
241
+ * Three sources, and the input decides which: EMPTY (just a name), FROM A WEBSITE
242
+ * (`websiteUrl` + `extract: true`), or A COPY (`duplicateFrom`).
243
+ *
244
+ * ⚠️ WITH `extract` IT RETURNS IMMEDIATELY, before the kit has any content. That empty kit is the HANDLE:
245
+ * the thing to poll and the row the UI renders at once. Poll `extractionStatus` via `getBrandKit`.
246
+ */
247
+ async createBrandKit(input) {
248
+ // A copy is a create with a source, so it shares this method rather than owning a verb of its own.
249
+ if (input.duplicateFrom) {
250
+ const { duplicateFrom, name } = input;
251
+ const data = await this.request('POST', `/api/v1/brand-kits/${encodeURIComponent(duplicateFrom)}/duplicate`, name ? { name } : {});
252
+ return { brandKit: data.brandKit };
253
+ }
254
+ return this.request('POST', '/api/v1/brand-kits', input);
255
+ }
256
+ /**
257
+ * Re-run website extraction for an existing kit. Returns at once; poll `extractionStatus`.
258
+ * Requires the kit to already have a `websiteUrl`, and the `brandkit:write` scope.
259
+ */
260
+ async extractBrandKit(brandKitId) {
261
+ const data = await this.request('POST', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/extract`);
262
+ return data.extraction;
263
+ }
264
+ /**
265
+ * Reorder the account's brand kits. Collection-level because ordering is a property of the SET: a per-kit
266
+ * position would let two kits claim one slot. Pass every id, in the order you want.
267
+ */
268
+ async reorderBrandKits(orderedIds) {
269
+ const data = await this.request('PATCH', '/api/v1/brand-kits', {
270
+ orderedIds,
271
+ });
138
272
  return data.brandKits;
139
273
  }
140
274
  /** Get one brand kit, fully assembled (the get half). Throws NotFoundError if absent. */
@@ -149,11 +283,6 @@ export class ContentHero {
149
283
  async updateBrandKit(brandKitId, input) {
150
284
  return this.request('PATCH', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}`, input);
151
285
  }
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
286
  /** Add a curated section to a brand kit. Requires the `brandkit:write` scope. */
158
287
  async addBrandKitSection(brandKitId, input) {
159
288
  const data = await this.request('POST', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/sections`, input);
@@ -164,11 +293,6 @@ export class ContentHero {
164
293
  const data = await this.request('PATCH', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/sections/${encodeURIComponent(sectionId)}`, input);
165
294
  return data.section;
166
295
  }
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
296
  // -------------------------------------------------------------------------
173
297
  // Brand knowledge (a brand kit's knowledge base)
174
298
  // -------------------------------------------------------------------------
@@ -206,9 +330,15 @@ export class ContentHero {
206
330
  async removeBrandKnowledge(brandKitId, knowledgeId) {
207
331
  return this.request('DELETE', `/api/v1/brand-kits/${encodeURIComponent(brandKitId)}/knowledge/${encodeURIComponent(knowledgeId)}`);
208
332
  }
209
- /** List the account's recent studio outputs (the list half of the list+get pair). */
333
+ /**
334
+ * List the account's recent media (the list half of the list+get pair). `source`
335
+ * selects the library: 'creations' (default, studio outputs) or 'uploads' (the
336
+ * editor Uploads tab).
337
+ */
210
338
  async listMedia(options = {}) {
211
339
  const q = new URLSearchParams();
340
+ if (options.source)
341
+ q.set('source', options.source);
212
342
  if (options.contentType) {
213
343
  const types = Array.isArray(options.contentType) ? options.contentType : [options.contentType];
214
344
  q.set('contentType', types.join(','));
@@ -217,6 +347,10 @@ export class ContentHero {
217
347
  q.set('status', options.status);
218
348
  if (options.kind)
219
349
  q.set('kind', options.kind);
350
+ if (options.favorited)
351
+ q.set('favorited', 'true');
352
+ if (options.archived)
353
+ q.set('archived', 'true');
220
354
  if (options.limit != null)
221
355
  q.set('limit', String(options.limit));
222
356
  if (options.offset != null)
@@ -226,12 +360,71 @@ export class ContentHero {
226
360
  return data.media;
227
361
  }
228
362
  /**
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.
363
+ * Get one media item by id token (the get half). `source` selects the library the
364
+ * id belongs to: 'creations' (default, a studio output; token may be the full id,
365
+ * its first 8 characters, or either with a `-N` variation suffix), 'uploads' (an
366
+ * editor Uploads-tab file; full id or short id, no variations), or 'stock' (a used
367
+ * stock item; full id or short id, no variations). Pass the same source the item
368
+ * reported in listMedia. Throws NotFoundError if absent.
369
+ */
370
+ async getMedia(idToken, options = {}) {
371
+ const qs = options.source ? `?source=${encodeURIComponent(options.source)}` : '';
372
+ return this.request('GET', `/api/v1/media/${encodeURIComponent(idToken)}${qs}`);
373
+ }
374
+ /**
375
+ * Semantically search the account's editable media library (creations, uploads, licensed stock, brand assets)
376
+ * by describing the content in natural language. Returns matching assets ranked by relevance, each with a
377
+ * description, tags, and, for videos, the timestamps of the scenes that matched, so a precise moment can be
378
+ * located. Searches only the account's own usable library, never inspiration, published posts, or knowledge.
379
+ */
380
+ async searchMedia(query, options = {}) {
381
+ const q = new URLSearchParams({ query });
382
+ if (options.kinds && options.kinds.length > 0)
383
+ q.set('kinds', options.kinds.join(','));
384
+ if (options.limit != null)
385
+ q.set('limit', String(options.limit));
386
+ const data = await this.request('GET', `/api/v1/media/search?${q.toString()}`);
387
+ return data.results;
388
+ }
389
+ // ─── Library folders (Unified Content Library, Phase D) ────────────────────
390
+ /** List the account's folders (manual + smart, with parent links) plus the built-in derived folders. */
391
+ async listFolders() {
392
+ return this.request('GET', '/api/v1/library/folders');
393
+ }
394
+ /** A folder's contents. `folderId` is a folder id or a derived key (recents|favorites|edits|canvas|posts). */
395
+ async getFolder(folderId) {
396
+ const data = await this.request('GET', `/api/v1/library/folders/${encodeURIComponent(folderId)}`);
397
+ return { folder: data.folder, items: data.items };
398
+ }
399
+ async createFolder(input) {
400
+ const data = await this.request('POST', '/api/v1/library/folders', input);
401
+ return data.folder;
402
+ }
403
+ async updateFolder(folderId, patch) {
404
+ const data = await this.request('PATCH', `/api/v1/library/folders/${encodeURIComponent(folderId)}`, patch);
405
+ return data.folder;
406
+ }
407
+ async deleteFolder(folderId) {
408
+ await this.request('DELETE', `/api/v1/library/folders/${encodeURIComponent(folderId)}`);
409
+ }
410
+ /** File an item into a manual folder (a pointer; no bytes move). */
411
+ async addToFolder(folderId, ref) {
412
+ await this.request('POST', `/api/v1/library/folders/${encodeURIComponent(folderId)}/items`, ref);
413
+ }
414
+ async removeFromFolder(folderId, ref) {
415
+ await this.request('DELETE', `/api/v1/library/folders/${encodeURIComponent(folderId)}/items`, ref);
416
+ }
417
+ /**
418
+ * Resolve a batch of media references to vision-ready URLs + light metadata (the
419
+ * micro drill-in behind get_context's macro screenshot). Each item is a raw
420
+ * `{ url }` or an `{ mediaId, variation? }`; a mediaId with no variation resolves
421
+ * to only the primary variation (siblings listed in `otherVariations`), never a
422
+ * whole generation. Returns one entry per item, in order; a bad item comes back
423
+ * with `ok: false` rather than failing the batch. Max 10 items per call (the SDK
424
+ * returns URLs; the MCP layer is what turns them into image blocks for a model).
232
425
  */
233
- async getMedia(idToken) {
234
- return this.request('GET', `/api/v1/media/${encodeURIComponent(idToken)}`);
426
+ async getMediaBatch(items) {
427
+ return this.request('POST', '/api/v1/media/batch', { items });
235
428
  }
236
429
  // -------------------------------------------------------------------------
237
430
  // Media upload (bring your own file or URL -> first-class media)
@@ -266,11 +459,23 @@ export class ContentHero {
266
459
  contentType: opts.contentType,
267
460
  sizeBytes,
268
461
  });
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.
462
+ // The PUT goes straight to object storage (not our API), so it uses a bare
463
+ // fetch with none of our auth headers.
464
+ //
465
+ // The headers come from the SERVER rather than being assumed here. Storage
466
+ // used to be Supabase, where Content-Type alone is enough; it is moving to
467
+ // R2, where the presigned URL signs the owner in as `x-amz-meta-user_id` and
468
+ // a PUT missing it is rejected with SignatureDoesNotMatch (verified: 403
469
+ // with Content-Type alone, 200 with both). Letting the server say what to
470
+ // send means this client never has to know which store it is talking to,
471
+ // and the migration needs no coordinated release of it.
472
+ //
473
+ // Falls back to Content-Type so an older API that does not return
474
+ // uploadHeaders keeps working, which is what makes the two deployable in
475
+ // either order.
271
476
  const put = await this.fetchImpl(created.uploadUrl, {
272
477
  method: 'PUT',
273
- headers: { 'Content-Type': opts.contentType },
478
+ headers: created.uploadHeaders ?? { 'Content-Type': opts.contentType },
274
479
  body: data,
275
480
  });
276
481
  if (!put.ok) {
@@ -399,10 +604,6 @@ export class ContentHero {
399
604
  const data = await this.request('PATCH', `/api/v1/posts/${encodeURIComponent(postId)}`, input);
400
605
  return data.post;
401
606
  }
402
- /** Archive a post (status -> 'archived'). No hard delete. */
403
- async archivePost(postId) {
404
- return this.updatePost(postId, { status: 'archived' });
405
- }
406
607
  /**
407
608
  * List the account's pipeline stages (sorted), seeding the defaults on first
408
609
  * access. Use this to resolve a stage before placing a post; stages are
@@ -510,6 +711,8 @@ export class ContentHero {
510
711
  q.set('sort_by', options.sortBy);
511
712
  if (options.brandKitId)
512
713
  q.set('brand_kit_id', options.brandKitId);
714
+ if (options.favorited)
715
+ q.set('favorited', 'true');
513
716
  if (options.limit != null)
514
717
  q.set('limit', String(options.limit));
515
718
  if (options.offset != null)
@@ -545,6 +748,281 @@ export class ContentHero {
545
748
  const data = await this.request('GET', `/api/v1/connected-accounts/${encodeURIComponent(accountId)}`);
546
749
  return data.account;
547
750
  }
751
+ // -------------------------------------------------------------------------
752
+ // Favorites & archive (universal set/clear across asset types)
753
+ // -------------------------------------------------------------------------
754
+ /**
755
+ * Mark an asset as favorited. Requires the `favorites:write` scope.
756
+ *
757
+ * Pass `{ assetType, id }` for a top-level asset (post, voice, brand_kit,
758
+ * project, inspiration_content, gallery), or `{ id, variationIndex }` to
759
+ * favorite a single studio output variation slot (id is a studio output id).
760
+ * Idempotent.
761
+ */
762
+ async favorite(input) {
763
+ await this.request('POST', '/api/v1/favorite', input);
764
+ }
765
+ /**
766
+ * Clear the favorite flag on an asset. Requires the `favorites:write` scope.
767
+ * Same target shape as `favorite`. Idempotent.
768
+ */
769
+ async unfavorite(input) {
770
+ await this.request('POST', '/api/v1/unfavorite', input);
771
+ }
772
+ /**
773
+ * Archive an asset. Requires the `favorites:write` scope.
774
+ *
775
+ * Pass `{ assetType, id }` for a top-level asset (post, brand_kit,
776
+ * brand_kit_section, project), or `{ id, variationIndex }` to archive a single
777
+ * studio output variation slot. Archiving a post sets its status to 'archived'.
778
+ * Idempotent.
779
+ */
780
+ async archive(input) {
781
+ await this.request('POST', '/api/v1/archive', input);
782
+ }
783
+ /**
784
+ * Unarchive an asset. Requires the `favorites:write` scope. Same target shape
785
+ * as `archive`. Unarchiving a post restores it to 'draft'. Idempotent.
786
+ */
787
+ async unarchive(input) {
788
+ await this.request('POST', '/api/v1/unarchive', input);
789
+ }
790
+ // -------------------------------------------------------------------------
791
+ // Editor / canvas ops (programmatic parity with the manual UI + in-app agent)
792
+ // -------------------------------------------------------------------------
793
+ /**
794
+ * List the caller's projects (both editor + canvas) as lightweight summaries. Filter by archived /
795
+ * favorited state, by `kind`, or by a title search. Requires the `editor:read` scope.
796
+ */
797
+ async listProjects(input = {}) {
798
+ const q = new URLSearchParams();
799
+ if (input.filter)
800
+ q.set('filter', input.filter);
801
+ if (input.kind)
802
+ q.set('kind', input.kind);
803
+ if (input.search)
804
+ q.set('search', input.search);
805
+ const qs = q.toString();
806
+ const { projects } = await this.request('GET', `/api/v1/projects${qs ? `?${qs}` : ''}`);
807
+ return projects;
808
+ }
809
+ /**
810
+ * Read a single project (metadata + composition `state` + `revision`), to read-before-write. Pass the returned
811
+ * `revision` back as `applyEditorOps`'s `expectedRevision`. By DEFAULT `state` is a lightweight SUMMARY (per-clip
812
+ * / per-layer structure, heavy payloads dropped); pass `detail:'full'` for the complete composition. For a
813
+ * timeline, `fromFrame`/`toFrame` (+ `trackId`) scope the read to the clips overlapping that frame window; for a
814
+ * canvas, `slideId` scopes it to a single slide, and applies to `detail:'full'` too so asking for one slide's
815
+ * detail does not pay for the whole deck. A canvas summary echoes each text layer's `text` (truncated), which is
816
+ * what identifies it. Requires the `editor:read` scope.
817
+ */
818
+ async getProject(projectId, options = {}) {
819
+ const params = new URLSearchParams();
820
+ if (options.includeRenderUrl)
821
+ params.set('includeRenderUrl', 'true');
822
+ if (options.detail === 'full')
823
+ params.set('detail', 'full');
824
+ if (typeof options.fromFrame === 'number')
825
+ params.set('fromFrame', String(options.fromFrame));
826
+ if (typeof options.toFrame === 'number')
827
+ params.set('toFrame', String(options.toFrame));
828
+ if (options.trackId)
829
+ params.set('trackId', options.trackId);
830
+ if (options.slideId)
831
+ params.set('slideId', options.slideId);
832
+ const qs = params.toString() ? `?${params.toString()}` : '';
833
+ const { project } = await this.request('GET', `/api/v1/projects/${encodeURIComponent(projectId)}${qs}`);
834
+ return project;
835
+ }
836
+ /**
837
+ * Read the LIVE context of what the user is currently viewing in the open app: the active surface + focus +
838
+ * selection, so an agent can operate on "what the user is looking at" like the internal assistant does. Fast
839
+ * and structured by default. Pass `capture: true` to also ping the live tab for a fresh viewport screenshot
840
+ * (returned as a short-lived `snapshotUrl`) when you need the user's screen as shown. To see the COMPOSED
841
+ * OUTPUT itself (the rendered editor frame or canvas slide), pass `render: true` (optionally `frame` for an
842
+ * editor project, or `slideId` / `slideIndex` for canvas); the render returns inline as a data URL, is
843
+ * ephemeral, and does not need a live tab. Returns the most-recent-active session's context plus the full live
844
+ * participant set. Optionally scope to one project. Requires the `context:read` scope.
845
+ */
846
+ async getContext(input = {}) {
847
+ const params = new URLSearchParams();
848
+ if (input.projectId)
849
+ params.set('projectId', input.projectId);
850
+ if (input.capture)
851
+ params.set('capture', 'true');
852
+ if (input.render)
853
+ params.set('render', 'true');
854
+ if (input.mode)
855
+ params.set('mode', input.mode);
856
+ if (typeof input.frame === 'number')
857
+ params.set('frame', String(input.frame));
858
+ if (input.slideId)
859
+ params.set('slideId', input.slideId);
860
+ if (typeof input.slideIndex === 'number')
861
+ params.set('slideIndex', String(input.slideIndex));
862
+ if (typeof input.fromFrame === 'number')
863
+ params.set('fromFrame', String(input.fromFrame));
864
+ if (typeof input.toFrame === 'number')
865
+ params.set('toFrame', String(input.toFrame));
866
+ if (typeof input.count === 'number')
867
+ params.set('count', String(input.count));
868
+ if (typeof input.width === 'number')
869
+ params.set('width', String(input.width));
870
+ // Canonical URI encoding: URLSearchParams renders a space as '+', which is x-www-form-urlencoded, not the
871
+ // RFC-3986 query encoding; emit %20 so the URL is canonical (both decode to a space server-side).
872
+ const query = params.toString().replace(/\+/g, '%20');
873
+ const qs = query ? `?${query}` : '';
874
+ return this.request('GET', `/api/v1/context${qs}`);
875
+ }
876
+ /**
877
+ * Create an async PREVIEW render (ephemeral, never stored). Currently a short low-res COMPOSED VIDEO of an
878
+ * editor range, so you can assess motion, cuts, transitions, and pacing a still cannot show. Returns a job
879
+ * handle; poll it with `getPreview`. Requires the `context:read` scope.
880
+ */
881
+ async createPreview(input) {
882
+ return this.request('POST', '/api/v1/preview', input);
883
+ }
884
+ /**
885
+ * Poll a preview started with `createPreview`. While rendering, returns `progress`; on completion, returns a
886
+ * short-lived signed `url` to the ephemeral output plus the estimated cost.
887
+ */
888
+ async getPreview(job) {
889
+ const params = new URLSearchParams({ renderId: job.renderId, bucketName: job.bucketName });
890
+ return this.request('GET', `/api/v1/preview?${params.toString()}`);
891
+ }
892
+ /**
893
+ * Create a project. All fields optional; the server applies the same defaults as the in-app new-project
894
+ * flow (16:9 landscape, `editor` kind). A new canvas starts with one empty slide; a new editor starts with
895
+ * an empty timeline. Returns the full detail (with its id + starting revision) so you can immediately apply
896
+ * ops. Requires the `editor:write` scope.
897
+ */
898
+ async createProject(input = {}) {
899
+ const { project } = await this.request('POST', '/api/v1/projects', input);
900
+ return project;
901
+ }
902
+ /**
903
+ * Import a PowerPoint / Google Slides file (by URL) or a Canva design (by id) into a NEW canvas project
904
+ * with editable layers, returning the created project's full detail. The Canva source uses the caller's
905
+ * Canva connection (a ConflictError-like 400 with code 'canva_not_connected' if not connected). Structured
906
+ * import can take a while (export + convert + parse). Requires the `editor:write` scope.
907
+ */
908
+ async importProject(input) {
909
+ const { project } = await this.request('POST', '/api/v1/projects/import', input);
910
+ return project;
911
+ }
912
+ /**
913
+ * PERMANENTLY delete a project (irreversible hard delete, distinct from the reversible archive). The
914
+ * `?confirm=true` opt-in is sent for you. To reversibly hide a project instead, use `archive`. Requires
915
+ * the `editor:write` scope.
916
+ */
917
+ async deleteProject(projectId) {
918
+ await this.request('DELETE', `/api/v1/projects/${encodeURIComponent(projectId)}?confirm=true`);
919
+ }
920
+ /**
921
+ * Apply a batch of ops to a project's composition (canvas slides or editor timeline) and persist
922
+ * atomically. The project's `surface` selects the op vocabulary; the ops run through the same reducers the manual
923
+ * UI and in-app agent use. Requires the `editor:write` scope.
924
+ *
925
+ * Optimistic concurrency: pass `expectedRevision` (from `getProject`) to fail with a 409 ConflictError if
926
+ * a concurrent edit landed, instead of clobbering it. Returns the new revision and the per-op results (a
927
+ * bad op is reported, never throws).
928
+ *
929
+ * Each op is given a client-generated `op_id` (uuid) here if it does not already have one, so the op has a
930
+ * stable identity from the point of intent: resending the same batch is idempotent (the server dedupes by
931
+ * op_id), and a live editor sees the edit as an attributed collaborator change keyed by that id. The
932
+ * assigned id is echoed back on each result's `opId`.
933
+ */
934
+ async applyEditorOps(input) {
935
+ const ops = input.ops.map((op) => typeof op.op_id === 'string' && op.op_id ? op : { ...op, op_id: globalThis.crypto.randomUUID() });
936
+ return this.request('POST', '/api/v1/editor/ops', { ...input, ops });
937
+ }
938
+ /**
939
+ * Start an export (render) of a project's saved composition. `mp4` returns a job with status 'rendering'
940
+ * (poll with getExport or use exportProjectAndWait); canvas still/document formats (png/jpg/pdf/pptx) run
941
+ * synchronously and return 'completed' with the outputUrl. Requires the `editor:write` scope.
942
+ */
943
+ async startExport(projectId, input = {}) {
944
+ return this.request('POST', `/api/v1/projects/${encodeURIComponent(projectId)}/export`, input);
945
+ }
946
+ /** Poll an export job by id. Requires the `editor:read` scope. */
947
+ async getExport(exportId) {
948
+ return this.request('GET', `/api/v1/exports/${encodeURIComponent(exportId)}`);
949
+ }
950
+ /** The kind-aware catalog of export formats + their options. Requires the `editor:read` scope. */
951
+ async getExportFormats() {
952
+ return this.request('GET', '/api/v1/export-formats');
953
+ }
954
+ /**
955
+ * Start an export and poll until it completes. Resolves with the completed job (outputUrl set), throws
956
+ * `GenerationFailedError` on failure, or `GenerationTimeoutError` if it does not finish within `timeoutMs`
957
+ * (the server job may still complete; re-poll with getExport).
958
+ */
959
+ async exportProjectAndWait(projectId, input = {}, options = {}) {
960
+ const job = await this.startExport(projectId, input);
961
+ if (job.status === 'completed' || job.status === 'failed') {
962
+ if (job.status === 'failed')
963
+ throw new GenerationFailedError(job.exportId, job.errorMessage ?? 'Export failed');
964
+ return job;
965
+ }
966
+ return this.waitForExport(job.exportId, options);
967
+ }
968
+ /** Poll an export job to a terminal state. */
969
+ async waitForExport(exportId, options = {}) {
970
+ const { pollIntervalMs = 3000, timeoutMs = 600_000, signal } = options;
971
+ const deadline = Date.now() + timeoutMs;
972
+ while (true) {
973
+ const job = await this.getExport(exportId);
974
+ if (job.status === 'completed')
975
+ return job;
976
+ if (job.status === 'failed')
977
+ throw new GenerationFailedError(exportId, job.errorMessage ?? 'Export failed');
978
+ if (Date.now() >= deadline)
979
+ throw new GenerationTimeoutError(exportId);
980
+ await sleep(pollIntervalMs, signal);
981
+ }
982
+ }
983
+ /**
984
+ * The canvas layer-type catalog (types + editable props), so you know what `update_canvas` ops can
985
+ * create/edit. Requires the `editor:read` scope.
986
+ */
987
+ async getLayerTypes() {
988
+ return this.request('GET', '/api/v1/editor/layer-types');
989
+ }
990
+ /**
991
+ * The editor timeline clip + track-type catalog (types + editable props), so you know what
992
+ * `update_timeline` ops can create/edit. Requires the `editor:read` scope.
993
+ */
994
+ async getTimelineTypes() {
995
+ return this.request('GET', '/api/v1/editor/timeline-types');
996
+ }
997
+ /**
998
+ * Read an editor project's transcript mapped to its timeline clips. Returns one segment per transcribable
999
+ * primary-track clip, in timeline order, carrying the words spoken within it plus its current enabled/disabled
1000
+ * state, so you can read what is said, see which parts are already cut, and target exact clipIds with
1001
+ * update_timeline (disable_ranges / set_disabled / delete_ranges). Scope with `search` (substring) or
1002
+ * `startMs`/`endMs` (source-media time) to fetch only the part you need. Pass `granularity: 'word'` for
1003
+ * word-level timing (with absolute timeline frames), per-word confidence + speaker, derived silence gaps, and
1004
+ * non-speech audio events. `paceThresholdMs` (minimum pause to report as silence) and `paddingStartMs` /
1005
+ * `paddingEndMs` (breathing room kept around speech; negative tightens) tune the silence detection and default
1006
+ * to the project's saved pace/padding. Requires the `editor:read` scope.
1007
+ */
1008
+ async getTranscript(projectId, options = {}) {
1009
+ const params = new URLSearchParams({ projectId });
1010
+ if (options.search)
1011
+ params.set('search', options.search);
1012
+ if (options.startMs !== undefined)
1013
+ params.set('startMs', String(options.startMs));
1014
+ if (options.endMs !== undefined)
1015
+ params.set('endMs', String(options.endMs));
1016
+ if (options.granularity)
1017
+ params.set('granularity', options.granularity);
1018
+ if (options.paceThresholdMs !== undefined)
1019
+ params.set('paceThresholdMs', String(options.paceThresholdMs));
1020
+ if (options.paddingStartMs !== undefined)
1021
+ params.set('paddingStartMs', String(options.paddingStartMs));
1022
+ if (options.paddingEndMs !== undefined)
1023
+ params.set('paddingEndMs', String(options.paddingEndMs));
1024
+ return this.request('GET', `/api/v1/editor/transcript?${params.toString()}`);
1025
+ }
548
1026
  /** Issue an authenticated request and map non-2xx responses to typed errors. */
549
1027
  async request(method, path, body) {
550
1028
  const headers = {
@@ -553,6 +1031,14 @@ export class ContentHero {
553
1031
  };
554
1032
  if (body !== undefined)
555
1033
  headers['Content-Type'] = 'application/json';
1034
+ // Release tracking (NOT a presence ping): presence is lit server-side, but the CLI still needs to clear its
1035
+ // badge promptly on exit, so record any project this client mutates by id. Local only, no extra request; the
1036
+ // activity endpoint manages its own lease and is skipped.
1037
+ if (body && typeof body === 'object' && !Array.isArray(body) && path !== '/api/v1/editor/activity') {
1038
+ const pid = body.projectId;
1039
+ if (typeof pid === 'string' && pid)
1040
+ this.activityPingedAt.set(pid, Date.now());
1041
+ }
556
1042
  const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
557
1043
  method,
558
1044
  headers,