@frontera-sdk/cli 1.50.79 → 1.50.81

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.
Files changed (37) hide show
  1. package/README.md +6 -0
  2. package/package.json +4 -4
  3. package/src/api/blueprint-authoring-api.ts +52 -0
  4. package/src/api/credential-failure.ts +34 -4
  5. package/src/api/dataset-api.ts +1 -1
  6. package/src/api/governed-action-api.ts +75 -7
  7. package/src/api/media-api.ts +181 -0
  8. package/src/api/platform-api.ts +59 -0
  9. package/src/api/workflow-api.ts +1 -1
  10. package/src/auth-verify.ts +1 -1
  11. package/src/commands/action/available.ts +59 -0
  12. package/src/commands/action/cancel.ts +34 -0
  13. package/src/commands/action/decide.ts +54 -0
  14. package/src/commands/action/deploy.ts +4 -1
  15. package/src/commands/action/index-commands.ts +16 -6
  16. package/src/commands/action/prepare.ts +3 -1
  17. package/src/commands/action/request-summary.ts +13 -0
  18. package/src/commands/action/requests.ts +6 -5
  19. package/src/commands/action/submit.ts +183 -0
  20. package/src/commands/blueprint/get.ts +139 -29
  21. package/src/commands/chat-app/index-commands.ts +475 -0
  22. package/src/commands/knowledge/upload-plan.ts +14 -8
  23. package/src/commands/marking/index-commands.ts +171 -0
  24. package/src/commands/media/index-commands.ts +592 -0
  25. package/src/commands/registry.ts +28 -6
  26. package/src/commands/types.ts +6 -0
  27. package/src/commands/workflow/list.ts +1 -1
  28. package/src/errors.ts +3 -2
  29. package/src/exit.ts +126 -1
  30. package/src/flag-help.ts +24 -2
  31. package/src/forge/touches.ts +2 -0
  32. package/src/harness.ts +5 -3
  33. package/src/main.ts +23 -38
  34. package/src/scopes.ts +43 -0
  35. package/src/template.ts +6 -4
  36. package/src/templates/next-app-files.ts +8 -4
  37. package/src/vendor/sdk-sources.json +16 -15
@@ -0,0 +1,592 @@
1
+ import { readFileSync, statSync } from 'node:fs'
2
+ import { basename, relative, sep } from 'node:path'
3
+
4
+ import { MediaApi, type ChunkingStatus, type ExtractionStatus, type MediaItem } from '../../api/media-api'
5
+ import { CliError, UsageError } from '../../errors'
6
+ import { table } from '../../table'
7
+ import { UPLOAD_CONCURRENCY } from '../knowledge/upload-batch'
8
+ import { extensionOf, planUploads, type PlannedFile } from '../knowledge/upload-plan'
9
+ import { flagBool, flagString, type Command, type CommandContext } from '../types'
10
+
11
+ /**
12
+ * Media Sets: governed document collections that extraction, chunking and
13
+ * Blueprint read from.
14
+ *
15
+ * Every verb here is `authLane: 'session'` — see `MediaApi` for why both key
16
+ * kinds are refused; the dispatcher words that refusal. The verbs cover one path: create a set, upload a
17
+ * directory, switch processing on, wait for it, spot-check with a search, and
18
+ * grant the set to the workspace that will use it.
19
+ */
20
+
21
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
22
+
23
+ /**
24
+ * The types a Media Set accepts, by extension.
25
+ *
26
+ * A mirror of `MEDIA_ALLOWED_TYPES` in the service, which stays the authority.
27
+ * The service reads the type the client DECLARES, so the mapping is not only a
28
+ * filter — a `.pdf` sent as `application/octet-stream` is refused.
29
+ */
30
+ export const MEDIA_MIME_BY_EXTENSION: Record<string, string> = {
31
+ '.pdf': 'application/pdf',
32
+ '.png': 'image/png',
33
+ '.jpg': 'image/jpeg',
34
+ '.jpeg': 'image/jpeg',
35
+ '.webp': 'image/webp',
36
+ '.gif': 'image/gif',
37
+ '.txt': 'text/plain',
38
+ '.csv': 'text/csv',
39
+ '.json': 'application/json',
40
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
41
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
42
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
43
+ }
44
+
45
+ /** The workspace for a next-step line: the one named, or a placeholder. */
46
+ function ws(ctx: CommandContext): string {
47
+ return ctx.workspaceId ?? '<workspaceId>'
48
+ }
49
+
50
+ function api(ctx: CommandContext): MediaApi {
51
+ return new MediaApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
52
+ }
53
+
54
+ /** Accept a set by API name or id — `media list` shows both. */
55
+ async function resolveSet(client: MediaApi, ref: string): Promise<string> {
56
+ if (UUID.test(ref)) return ref
57
+ const sets = await client.list()
58
+ const match = sets.find((s) => s.apiName.toLowerCase() === ref.toLowerCase())
59
+ if (match) return match.id
60
+ throw new CliError(`no Media Set named "${ref}"`, {
61
+ code: 'NOT_FOUND',
62
+ // Without an administrator's view, the list is only what the named
63
+ // workspace was granted — so a set you just created may be absent here.
64
+ hint: `known sets: ${sets.map((s) => s.apiName).join(', ') || '(none visible)'} — `
65
+ + 'or pass the set id from `frontera media list --workspace <id>`',
66
+ })
67
+ }
68
+
69
+ function requireSetArg(ctx: CommandContext, verb: string): string {
70
+ const ref = ctx.positional[0]
71
+ if (!ref) throw new UsageError('missing <set>', `frontera media list --workspace <id> — then \`frontera media ${verb} <apiName>\``)
72
+ return ref
73
+ }
74
+
75
+ function positiveNumber(ctx: CommandContext, flag: string, integer: boolean): number | undefined {
76
+ const raw = flagString(ctx, flag)
77
+ if (raw === undefined) return undefined
78
+ const value = Number(raw)
79
+ if (Number.isNaN(value) || value < 0 || (integer && (!Number.isInteger(value) || value < 1))) {
80
+ throw new UsageError(
81
+ `--${flag} must be a ${integer ? 'positive whole number' : 'non-negative number'}, not "${raw}"`,
82
+ integer ? `try --${flag} 5` : `try --${flag} 0.5`,
83
+ )
84
+ }
85
+ return value
86
+ }
87
+
88
+ const list: Command = {
89
+ meta: {
90
+ noun: 'media',
91
+ verb: 'list',
92
+ authLane: 'session',
93
+ args: [],
94
+ flags: {},
95
+ summary: 'List the Media Sets visible to you in a workspace',
96
+ sessionAlternative: 'Blueprint → Media Sets',
97
+ examples: ['frontera media list --workspace <workspaceId>', 'frontera media list --workspace <workspaceId> --json'],
98
+ },
99
+ async run(ctx) {
100
+ const sets = await api(ctx).list()
101
+ return {
102
+ data: sets,
103
+ text:
104
+ sets.length === 0
105
+ ? 'No Media Sets visible. Create one with `frontera media create <apiName>`.'
106
+ : table(
107
+ ['apiName', 'name', 'items', 'id'],
108
+ sets.map((s) => [s.apiName, s.displayName, String(s.itemCount ?? ''), s.id]),
109
+ [undefined, 40, undefined, undefined],
110
+ ),
111
+ }
112
+ },
113
+ }
114
+
115
+ const create: Command = {
116
+ meta: {
117
+ noun: 'media',
118
+ verb: 'create',
119
+ scope: 'organization',
120
+ authLane: 'session',
121
+ args: [{ name: 'apiName', required: true, description: 'letters, digits and underscores, starting with a letter' }],
122
+ flags: { name: 'string', description: 'string' },
123
+ summary: 'Create a Media Set',
124
+ sessionAlternative: 'Blueprint → Media Sets → Create set',
125
+ examples: [
126
+ 'frontera media create contracts',
127
+ 'frontera media create contracts --name "Signed contracts" --description "MSAs"',
128
+ ],
129
+ },
130
+ async run(ctx) {
131
+ const apiName = ctx.positional[0]
132
+ if (!apiName) throw new UsageError('missing <apiName>', 'frontera media create contracts --name "Signed contracts"')
133
+ const description = flagString(ctx, 'description')
134
+ const set = await api(ctx).create({
135
+ apiName,
136
+ displayName: flagString(ctx, 'name') ?? apiName,
137
+ ...(description ? { description } : {}),
138
+ })
139
+ return {
140
+ data: set,
141
+ text: [
142
+ `created ${set.apiName} ${set.id}`,
143
+ // Grant first: upload and search act inside a workspace, and the
144
+ // service refuses both for a workspace the set is not granted to.
145
+ ` next: frontera media grant ${set.apiName} <workspaceId> --permissions read,write`,
146
+ ` then: frontera media upload ${set.apiName} <path…> --workspace <workspaceId>`,
147
+ ].join('\n'),
148
+ }
149
+ },
150
+ }
151
+
152
+ /**
153
+ * The service's hard ceiling on one file, whatever a deployment configures
154
+ * below it. Checked before reading so a stray disk image is not pulled into
155
+ * memory four at a time just to be refused.
156
+ */
157
+ export const MEDIA_MAX_BYTES = 50 * 1024 * 1024
158
+
159
+ interface UploadOutcome {
160
+ file: string
161
+ logicalPath: string
162
+ /** `skipped`: `--skip-existing`, and the set already holds this path. */
163
+ status: 'uploaded' | 'failed' | 'skipped'
164
+ item?: MediaItem
165
+ error?: string
166
+ /** The failure's error code, so an all-failed batch can name its cause. */
167
+ code?: string
168
+ /** The failure's own recovery, carried through an all-failed rethrow. */
169
+ hint?: string
170
+ }
171
+
172
+ /**
173
+ * The item's path inside the set. A file found by walking a directory keeps
174
+ * its place under that directory, so `contracts/2024/a.pdf` and
175
+ * `contracts/2025/a.pdf` stay two items instead of versioning each other.
176
+ */
177
+ function logicalPathOf(file: PlannedFile): string {
178
+ return file.root === undefined ? basename(file.path) : relative(file.root, file.path).split(sep).join('/')
179
+ }
180
+
181
+ const upload: Command = {
182
+ meta: {
183
+ noun: 'media',
184
+ verb: 'upload',
185
+ authLane: 'session',
186
+ args: [
187
+ { name: 'set', required: true, description: 'Media Set API name or id, from `frontera media list`' },
188
+ { name: 'path...', required: true, description: 'files or directories; directories are walked, unsupported types skipped' },
189
+ ],
190
+ flags: { markings: 'string', 'skip-existing': 'boolean' },
191
+ summary: 'Upload local files into a Media Set; the same path again adds a new version',
192
+ sessionAlternative: 'Blueprint → Media Sets → Files',
193
+ examples: [
194
+ 'frontera media upload contracts ./contracts --workspace <workspaceId>',
195
+ 'frontera media upload contracts msa.pdf --workspace <workspaceId>',
196
+ 'frontera media upload contracts ./contracts --workspace <id> --skip-existing',
197
+ ],
198
+ },
199
+ async run(ctx) {
200
+ const [ref, ...paths] = ctx.positional
201
+ if (!ref) throw new UsageError('missing <set>', 'frontera media list --workspace <id> — then pass an API name or id')
202
+ if (paths.length === 0) throw new UsageError('missing <path...>', `frontera media upload ${ref} <file-or-directory…>`)
203
+
204
+ const plan = planUploads(paths, Object.keys(MEDIA_MIME_BY_EXTENSION))
205
+ if (plan.rejected.length > 0) {
206
+ // Same rule as `knowledge upload`: a path the caller named that cannot go
207
+ // is a mistake in the command, so nothing is sent.
208
+ const first = plan.rejected[0]!
209
+ throw new UsageError(`cannot upload ${first.path}: ${first.reason}`, 'fix the path, or pass a directory and let unsupported types be skipped')
210
+ }
211
+ if (plan.files.length === 0) {
212
+ throw new CliError('nothing to upload', {
213
+ code: 'USAGE',
214
+ hint: plan.skippedByType > 0 ? `${plan.skippedByType} file(s) found, none of a supported type` : 'the paths given contain no files',
215
+ })
216
+ }
217
+
218
+ const client = api(ctx)
219
+ const setId = await resolveSet(client, ref)
220
+ const markings = flagString(ctx, 'markings')
221
+ // Re-running a directory re-versions every file that already landed, so a
222
+ // retry after a partial failure asks for only what the set lacks.
223
+ const existing = flagBool(ctx, 'skip-existing')
224
+ ? new Set((await client.items(setId)).filter((i) => i.isLatest).map((i) => i.path))
225
+ : new Set<string>()
226
+
227
+ // A failure never ends the batch; outcomes stay in plan order.
228
+ const outcomes: UploadOutcome[] = new Array(plan.files.length)
229
+ let next = 0
230
+ const worker = async (): Promise<void> => {
231
+ for (let index = next++; index < plan.files.length; index = next++) {
232
+ const planned = plan.files[index]!
233
+ const logicalPath = logicalPathOf(planned)
234
+ if (existing.has(logicalPath)) {
235
+ outcomes[index] = { file: planned.path, logicalPath, status: 'skipped' }
236
+ continue
237
+ }
238
+ try {
239
+ // Inside the try: a file removed between the walk and this read
240
+ // fails only itself, not the batch.
241
+ if (statSync(planned.path).size > MEDIA_MAX_BYTES) {
242
+ throw new CliError('larger than the 50 MB Media Set limit', { code: 'BAD_REQUEST' })
243
+ }
244
+ const item = await client.upload(setId, {
245
+ bytes: new Uint8Array(readFileSync(planned.path)),
246
+ filename: basename(planned.path),
247
+ mime: MEDIA_MIME_BY_EXTENSION[extensionOf(planned.path)]!,
248
+ logicalPath,
249
+ ...(markings ? { markings } : {}),
250
+ })
251
+ outcomes[index] = { file: planned.path, logicalPath, status: 'uploaded', item }
252
+ } catch (err) {
253
+ outcomes[index] = {
254
+ file: planned.path,
255
+ logicalPath,
256
+ status: 'failed',
257
+ error: err instanceof Error ? err.message : String(err),
258
+ ...(err instanceof CliError ? { code: err.code } : {}),
259
+ ...(err instanceof CliError && err.hint ? { hint: err.hint } : {}),
260
+ }
261
+ }
262
+ }
263
+ }
264
+ await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, plan.files.length) }, () => worker()))
265
+
266
+ const count = (status: UploadOutcome['status']) => outcomes.filter((o) => o.status === status).length
267
+ const uploaded = count('uploaded')
268
+ const failed = count('failed')
269
+ const present = count('skipped')
270
+ const rerun = `frontera media upload ${ref} ${paths.join(' ')} --workspace ${ws(ctx)} --skip-existing`
271
+ if (uploaded === 0 && failed > 0) {
272
+ const firstFailure = outcomes.find((o) => o.status === 'failed')!
273
+ // A permission refusal is not a per-file fault. Keys never get this far
274
+ // (the dispatcher refuses them before any request), but a person whose
275
+ // role lacks upload is refused on every file: surface that as itself,
276
+ // exit 4, rather than as a retryable batch count.
277
+ if (
278
+ (firstFailure.code === 'UNAUTHORIZED' || firstFailure.code === 'FORBIDDEN')
279
+ && outcomes.every((o) => o.status !== 'failed' || o.code === firstFailure.code)
280
+ ) {
281
+ throw new CliError(firstFailure.error ?? 'refused', {
282
+ code: firstFailure.code,
283
+ ...(firstFailure.hint ? { hint: firstFailure.hint } : {}),
284
+ })
285
+ }
286
+ // The upload route answers "not found" for a set this workspace holds no
287
+ // write grant on — the same answer as for no set at all, by design.
288
+ const ungranted = outcomes.every((o) => o.status !== 'failed' || o.code === 'NOT_FOUND')
289
+ throw new CliError(`all ${failed} upload(s) failed: ${firstFailure.error ?? 'unknown error'}`, {
290
+ code: 'FAILURE',
291
+ hint: ungranted
292
+ ? `the set may not be granted to workspace ${ws(ctx)} — `
293
+ + `frontera media grant ${ref} ${ws(ctx)} --permissions read,write`
294
+ : 'fix the cause above, then re-run — a failed upload creates no item',
295
+ })
296
+ }
297
+
298
+ const lines: string[] = []
299
+ if (failed > 0) lines.push(`${failed} of ${outcomes.length} FAILED — see below`)
300
+ lines.push(
301
+ `uploaded ${uploaded} file(s) into ${ref}`
302
+ + (present > 0 ? `, ${present} already present` : '')
303
+ + (plan.skippedByType > 0 ? `, skipped ${plan.skippedByType} of an unsupported type` : ''),
304
+ )
305
+ lines.push(table(
306
+ ['path', 'status', 'version', 'detail'],
307
+ outcomes.map((o) => [o.logicalPath, o.status, o.item ? String(o.item.version) : '', o.error ?? o.item?.mediaItemId ?? '']),
308
+ [50, undefined, undefined, 60],
309
+ ))
310
+ if (failed > 0) lines.push(` send only what is missing, without re-versioning the rest: ${rerun}`)
311
+ lines.push(` processing runs asynchronously — frontera media status ${ref} --watch --workspace ${ws(ctx)}`)
312
+ return {
313
+ data: { set: ref, setId, uploaded, failed, alreadyPresent: present, skippedByType: plan.skippedByType, items: outcomes },
314
+ text: lines.join('\n'),
315
+ }
316
+ },
317
+ }
318
+
319
+ const processCommand: Command = {
320
+ meta: {
321
+ noun: 'media',
322
+ verb: 'process',
323
+ scope: 'organization',
324
+ authLane: 'session',
325
+ args: [{ name: 'set', required: true, description: 'Media Set API name or id' }],
326
+ flags: { ocr: 'boolean', 'skip-embed': 'boolean' },
327
+ summary: 'Switch on text extraction, chunking and embedding for a Media Set',
328
+ sessionAlternative: 'Blueprint → Media Sets → Processing',
329
+ examples: [
330
+ 'frontera media process contracts',
331
+ 'frontera media process scanned_invoices --ocr',
332
+ 'frontera media process contracts --skip-embed',
333
+ ],
334
+ },
335
+ async run(ctx) {
336
+ const ref = requireSetArg(ctx, 'process')
337
+ const client = api(ctx)
338
+ const setId = await resolveSet(client, ref)
339
+ const embed = !flagBool(ctx, 'skip-embed')
340
+
341
+ // Extraction first: the service refuses chunking on a set that does not
342
+ // extract, because passages are cut from extracted text.
343
+ await client.setExtraction(setId, { enabled: true, ...(flagBool(ctx, 'ocr') ? { profile: 'ocr' as const } : {}) })
344
+ await client.setChunking(setId, { enabled: true, embed: { enabled: embed } })
345
+
346
+ return {
347
+ data: { setId, extraction: true, chunking: true, embedding: embed },
348
+ text: [
349
+ `processing on for ${ref}: extraction${flagBool(ctx, 'ocr') ? ' (ocr)' : ''}, chunking${embed ? ', embedding' : ''}`,
350
+ ` existing items are queued too — frontera media status ${ref} --watch --workspace <workspaceId>`,
351
+ ].join('\n'),
352
+ }
353
+ },
354
+ }
355
+
356
+ /** Polling cadence for `status --watch`. Mutable so a test can run it without sleeping. */
357
+ export const WAIT = { intervalMs: 5_000, timeoutMs: 10 * 60_000 }
358
+
359
+ function settled(extraction: ExtractionStatus, chunking: ChunkingStatus): boolean {
360
+ const e = extraction.extraction.extractEnabled ? extraction.coverage.pending : 0
361
+ const c = chunking.chunking.chunkEnabled ? chunking.coverage.pending : 0
362
+ const m = chunking.chunking.chunkEnabled && chunking.chunking.embedEnabled ? chunking.coverage.embedPending : 0
363
+ return e + c + m === 0
364
+ }
365
+
366
+ function renderStatus(ref: string, workspace: string, extraction: ExtractionStatus, chunking: ChunkingStatus): string {
367
+ const x = extraction.coverage
368
+ const c = chunking.coverage
369
+ const on = (enabled: boolean, detail = ''): string => (enabled ? `on${detail}` : 'off')
370
+ const embedOn = chunking.chunking.chunkEnabled && chunking.chunking.embedEnabled
371
+ const lines = [
372
+ table(
373
+ ['stage', 'state', 'ready', 'pending', 'failed', 'skipped', 'detail'],
374
+ [
375
+ ['extraction', on(extraction.extraction.extractEnabled, ` (${extraction.extraction.extractProfile})`),
376
+ `${x.ready}/${x.items}`, String(x.pending), String(x.failed), String(x.skipped), `${x.items} item(s)`],
377
+ ['chunking', on(chunking.chunking.chunkEnabled),
378
+ `${c.ready}/${c.extracts}`, String(c.pending), String(c.failed), String(c.skipped),
379
+ `${c.chunks} passage(s)${c.truncated > 0 ? `, ${c.truncated} document(s) truncated` : ''}`],
380
+ ['embedding', on(embedOn),
381
+ `${c.embedded}/${c.chunks}`, String(c.embedPending), String(c.embedFailed), '', chunking.chunking.embedModel ?? 'no model set'],
382
+ ],
383
+ ),
384
+ ]
385
+ if (!extraction.extraction.extractEnabled) lines.push(` nothing is processed until you run \`frontera media process ${ref}\``)
386
+ if (x.failed + c.failed + c.embedFailed > 0) lines.push(` after fixing the cause: frontera media retry ${ref}`)
387
+ if (embedOn && c.embedded > 0) lines.push(` spot-check: frontera media search ${ref} "<a question the documents answer>" --workspace ${workspace}`)
388
+ return lines.join('\n')
389
+ }
390
+
391
+ const status: Command = {
392
+ meta: {
393
+ noun: 'media',
394
+ verb: 'status',
395
+ authLane: 'session',
396
+ args: [{ name: 'set', required: true, description: 'Media Set API name or id' }],
397
+ flags: { watch: 'boolean' },
398
+ summary: 'Show extraction, chunking and embedding progress for a Media Set',
399
+ sessionAlternative: 'Blueprint → Media Sets → Processing',
400
+ examples: [
401
+ 'frontera media status contracts --workspace <workspaceId>',
402
+ 'frontera media status contracts --workspace <workspaceId> --watch --json',
403
+ ],
404
+ },
405
+ async run(ctx) {
406
+ const ref = requireSetArg(ctx, 'status')
407
+ const client = api(ctx)
408
+ const setId = await resolveSet(client, ref)
409
+ const read = () => Promise.all([client.extraction(setId), client.chunking(setId)])
410
+
411
+ let [extraction, chunking] = await read()
412
+ if (flagBool(ctx, 'watch')) {
413
+ const deadline = Date.now() + WAIT.timeoutMs
414
+ while (!settled(extraction, chunking)) {
415
+ if (Date.now() >= deadline) {
416
+ throw new CliError(`${ref} is still processing after ${Math.round(WAIT.timeoutMs / 60_000)} minutes`, {
417
+ code: 'FAILURE',
418
+ hint: `re-run \`frontera media status ${ref} --watch --workspace ${ws(ctx)}\` — the work continues server-side`,
419
+ })
420
+ }
421
+ await new Promise((r) => setTimeout(r, WAIT.intervalMs))
422
+ ;[extraction, chunking] = await read()
423
+ }
424
+ }
425
+
426
+ return {
427
+ data: { setId, extraction, chunking, settled: settled(extraction, chunking) },
428
+ text: renderStatus(ref, ws(ctx), extraction, chunking),
429
+ }
430
+ },
431
+ }
432
+
433
+ const retry: Command = {
434
+ meta: {
435
+ noun: 'media',
436
+ verb: 'retry',
437
+ scope: 'organization',
438
+ authLane: 'session',
439
+ args: [{ name: 'set', required: true, description: 'Media Set API name or id' }],
440
+ flags: {},
441
+ summary: 'Queue a Media Set’s failed extractions, chunkings and embeddings again',
442
+ sessionAlternative: 'Blueprint → Media Sets → Processing',
443
+ examples: ['frontera media retry contracts'],
444
+ },
445
+ async run(ctx) {
446
+ const ref = requireSetArg(ctx, 'retry')
447
+ const client = api(ctx)
448
+ const setId = await resolveSet(client, ref)
449
+ const extraction = await client.retryExtraction(setId)
450
+ const chunking = await client.retryChunking(setId)
451
+ return {
452
+ data: { setId, extraction: extraction.retried, chunking: chunking.retried.chunking, embedding: chunking.retried.embedding },
453
+ text: [
454
+ `re-queued ${extraction.retried} extraction(s), ${chunking.retried.chunking} chunking(s), ${chunking.retried.embedding} embedding(s)`,
455
+ ` frontera media status ${ref} --watch --workspace <workspaceId>`,
456
+ ].join('\n'),
457
+ }
458
+ },
459
+ }
460
+
461
+ /**
462
+ * Retrieval, which is the only thing that proves processing produced text an
463
+ * agent can find. `status` counts passages; it cannot say they are useful.
464
+ */
465
+ const search: Command = {
466
+ meta: {
467
+ noun: 'media',
468
+ verb: 'search',
469
+ authLane: 'session',
470
+ args: [
471
+ { name: 'set', required: true, description: 'Media Set API name or id' },
472
+ { name: 'query', required: true, description: 'text to search for, as an agent would ask it' },
473
+ ],
474
+ flags: { limit: 'string', 'max-distance': 'string' },
475
+ summary: 'Search a Media Set’s passages and show the nearest matches',
476
+ sessionAlternative: 'Blueprint → Media Sets → Processing',
477
+ examples: [
478
+ 'frontera media search contracts "termination notice" --workspace <id>',
479
+ 'frontera media search contracts "governing law" --workspace <id> --limit 3',
480
+ ],
481
+ },
482
+ async run(ctx) {
483
+ const [ref, ...queryParts] = ctx.positional
484
+ if (!ref) throw new UsageError('missing <set>', 'frontera media list --workspace <id> — then pass an API name or id')
485
+ const query = queryParts.join(' ').trim()
486
+ if (!query) throw new UsageError('missing <query>', `frontera media search ${ref} "what a user would ask"`)
487
+ const limit = positiveNumber(ctx, 'limit', true)
488
+ const maxDistance = positiveNumber(ctx, 'max-distance', false)
489
+
490
+ const client = api(ctx)
491
+ const setId = await resolveSet(client, ref)
492
+ const result = await client.search(setId, {
493
+ query,
494
+ ...(limit === undefined ? {} : { limit }),
495
+ ...(maxDistance === undefined ? {} : { maxDistance }),
496
+ })
497
+
498
+ if (result.matches.length === 0) {
499
+ return {
500
+ data: result,
501
+ text: `No passages matched. Check \`frontera media status ${ref} --workspace ${ws(ctx)}\` — only embedded passages are searchable.`,
502
+ }
503
+ }
504
+ return {
505
+ data: result,
506
+ text: table(
507
+ ['distance', 'path', 'passage'],
508
+ result.matches.map((m) => [m.distance.toFixed(3), m.path, m.text.replace(/\s+/g, ' ').trim()]),
509
+ [undefined, 40, 90],
510
+ ),
511
+ }
512
+ },
513
+ }
514
+
515
+ const GRANT_PERMISSIONS = ['read', 'write', 'manage', 'read_sensitive']
516
+
517
+ function requireWorkspaceArg(ctx: CommandContext, verb: string): { ref: string; workspaceId: string } {
518
+ const [ref, workspaceId] = ctx.positional
519
+ if (!ref) throw new UsageError('missing <set>', 'frontera media list --workspace <id> — then pass an API name or id')
520
+ if (!workspaceId || !UUID.test(workspaceId)) {
521
+ throw new UsageError(
522
+ workspaceId ? `<workspace> must be a workspace id, not "${workspaceId}"` : 'missing <workspace>',
523
+ `frontera workspace list — then \`frontera media ${verb} ${ref} <workspaceId>\``,
524
+ )
525
+ }
526
+ return { ref, workspaceId }
527
+ }
528
+
529
+ const grant: Command = {
530
+ meta: {
531
+ noun: 'media',
532
+ verb: 'grant',
533
+ scope: 'organization',
534
+ authLane: 'session',
535
+ args: [
536
+ { name: 'set', required: true, description: 'Media Set API name or id' },
537
+ { name: 'workspace', required: true, description: 'workspace id, from `frontera workspace list`' },
538
+ ],
539
+ flags: { permissions: 'string' },
540
+ summary: 'Grant a workspace access to a Media Set; re-granting replaces its permissions',
541
+ sessionAlternative: 'Blueprint → Media Sets → Access',
542
+ examples: [
543
+ 'frontera media grant contracts 7f3c9a2e-1b4d-4e8f-9a6c-2d5e8f1a3b7c',
544
+ 'frontera media grant contracts <workspaceId> --permissions read,write',
545
+ ],
546
+ },
547
+ async run(ctx) {
548
+ const { ref, workspaceId } = requireWorkspaceArg(ctx, 'grant')
549
+ const raw = flagString(ctx, 'permissions')
550
+ const permissions = raw?.split(',').map((p) => p.trim()).filter(Boolean)
551
+ const unknown = permissions?.find((p) => !GRANT_PERMISSIONS.includes(p))
552
+ if (unknown) throw new UsageError(`unknown permission "${unknown}"`, `one or more of: ${GRANT_PERMISSIONS.join(', ')}`)
553
+
554
+ const client = api(ctx)
555
+ const setId = await resolveSet(client, ref)
556
+ const result = await client.grant(setId, { workspaceId, ...(permissions?.length ? { permissions } : {}) })
557
+ return {
558
+ data: { setId, workspaceId, permissions: result.permissions },
559
+ text: `granted ${ref} to workspace ${workspaceId}: ${result.permissions.join(', ')}`,
560
+ }
561
+ },
562
+ }
563
+
564
+ const revoke: Command = {
565
+ meta: {
566
+ noun: 'media',
567
+ verb: 'revoke',
568
+ scope: 'organization',
569
+ authLane: 'session',
570
+ args: [
571
+ { name: 'set', required: true, description: 'Media Set API name or id' },
572
+ { name: 'workspace', required: true, description: 'workspace id' },
573
+ ],
574
+ flags: {},
575
+ summary: 'Remove a workspace’s access to a Media Set',
576
+ sessionAlternative: 'Blueprint → Media Sets → Access',
577
+ examples: ['frontera media revoke contracts 7f3c9a2e-1b4d-4e8f-9a6c-2d5e8f1a3b7c'],
578
+ },
579
+ async run(ctx) {
580
+ const { ref, workspaceId } = requireWorkspaceArg(ctx, 'revoke')
581
+ const client = api(ctx)
582
+ const setId = await resolveSet(client, ref)
583
+ await client.revoke(setId, workspaceId)
584
+ return {
585
+ data: { setId, workspaceId, revoked: true },
586
+ // Said explicitly: the set and its items are untouched.
587
+ text: `revoked workspace ${workspaceId} from ${ref} — the set and its items are unchanged`,
588
+ }
589
+ },
590
+ }
591
+
592
+ export const mediaCommands: Command[] = [list, create, upload, processCommand, status, retry, search, grant, revoke]