@indigoai-us/hq-cli 5.345.143 → 5.345.145

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 (69) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/dist/command-catalog.generated.d.ts +286 -6
  3. package/dist/command-catalog.generated.js +351 -6
  4. package/dist/command-registration-plan.d.ts +6 -0
  5. package/dist/command-registration-plan.js +1 -0
  6. package/dist/commands/agent-enroll.d.ts +1 -0
  7. package/dist/commands/agent-enroll.js +7 -0
  8. package/dist/commands/agents.d.ts +25 -3
  9. package/dist/commands/agents.js +57 -4
  10. package/dist/commands/api-keys.js +66 -8
  11. package/dist/commands/billing-sponsor.d.ts +4 -0
  12. package/dist/commands/billing-sponsor.js +48 -5
  13. package/dist/commands/billing-usage.d.ts +59 -0
  14. package/dist/commands/billing-usage.js +162 -0
  15. package/dist/commands/billing.d.ts +9 -0
  16. package/dist/commands/billing.js +29 -2
  17. package/dist/commands/core-checkpoint-native.d.ts +30 -0
  18. package/dist/commands/core-checkpoint-native.js +167 -10
  19. package/dist/commands/core-job-notify-native.js +53 -3
  20. package/dist/commands/core-jobs-native.d.ts +8 -0
  21. package/dist/commands/core-jobs-native.js +34 -2
  22. package/dist/commands/core-policy.js +76 -13
  23. package/dist/commands/library-add.d.ts +120 -0
  24. package/dist/commands/library-add.js +434 -0
  25. package/dist/commands/library-caption.d.ts +112 -0
  26. package/dist/commands/library-caption.js +393 -0
  27. package/dist/commands/library-link.d.ts +32 -0
  28. package/dist/commands/library-link.js +95 -0
  29. package/dist/commands/library.d.ts +72 -0
  30. package/dist/commands/library.js +527 -0
  31. package/dist/commands/meetings.d.ts +8 -0
  32. package/dist/commands/meetings.js +25 -2
  33. package/dist/commands/members.d.ts +20 -0
  34. package/dist/commands/members.js +51 -2
  35. package/dist/commands/storage-offload.d.ts +16 -2
  36. package/dist/commands/storage-offload.js +55 -4
  37. package/dist/commands/storage.d.ts +8 -1
  38. package/dist/commands/storage.js +6 -2
  39. package/dist/commands/whoami.js +9 -0
  40. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2410 -2063
  41. package/dist/hq-cloud-sync-runner/package.json +1 -1
  42. package/dist/lib/billing/plan-lock.d.ts +15 -1
  43. package/dist/lib/billing/plan-lock.js +45 -5
  44. package/dist/lib/billing/usage-card.d.ts +82 -0
  45. package/dist/lib/billing/usage-card.js +198 -0
  46. package/dist/lib/billing/usage-cost.d.ts +214 -0
  47. package/dist/lib/billing/usage-cost.js +440 -0
  48. package/dist/lib/billing/usage-only.d.ts +87 -0
  49. package/dist/lib/billing/usage-only.js +262 -0
  50. package/dist/lib/billing/usage-rate-card.d.ts +51 -0
  51. package/dist/lib/billing/usage-rate-card.js +62 -0
  52. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  53. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  54. package/dist/lib/plan-limit-nag.d.ts +4 -6
  55. package/dist/lib/plan-limit-nag.js +19 -1
  56. package/dist/lib/provider-launcher.d.ts +22 -0
  57. package/dist/lib/provider-launcher.js +65 -0
  58. package/dist/lib/search-index/index.d.ts +2 -0
  59. package/dist/lib/search-index/index.js +34 -0
  60. package/dist/lib/search-index/max-doc-bytes.d.ts +2 -1
  61. package/dist/lib/search-index/max-doc-bytes.js +2 -1
  62. package/dist/main.d.ts +9 -0
  63. package/dist/main.js +20 -3
  64. package/dist/utils/billing-gate.d.ts +19 -4
  65. package/dist/utils/billing-gate.js +25 -4
  66. package/dist/utils/plan-gate-error.d.ts +10 -0
  67. package/dist/utils/plan-gate-error.js +54 -0
  68. package/dist/utils/vault-api.js +4 -1
  69. package/package.json +2 -2
@@ -0,0 +1,393 @@
1
+ /**
2
+ * AI captions for library assets (US-006): `hq library add` captions each
3
+ * image or video it adds, and `hq library describe <placeholder>` re-runs it.
4
+ *
5
+ * Captioning runs on the user's machine through the local Claude Code CLI
6
+ * (`claude -p`, via the provider-launcher seam). No cloud caption service is
7
+ * called. Up to 4 frames are written to a private temp dir: an image is used
8
+ * as-is when it is a png/jpeg/gif/webp under the frame limit, otherwise ffmpeg
9
+ * re-encodes it to a 1024px JPEG. A video is sampled at up to 4 seek points,
10
+ * falling back to the first frame. Claude Code reads the frames with its Read
11
+ * tool only and replies with JSON { description, tags }.
12
+ *
13
+ * Captioning never fails an upload. When Claude Code is not installed, not
14
+ * signed in, errors, or times out, the item gets one plain "skipped" note.
15
+ */
16
+ import crypto from "node:crypto";
17
+ import { execFile } from "node:child_process";
18
+ import { captureProviderRun } from "../lib/provider-launcher.js";
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { pipeline } from "node:stream/promises";
23
+ import { formatCloudPlaceholder, parseCloudPlaceholder, } from "@indigoai-us/hq-cloud";
24
+ import { contentTypeFor, mediaKindFor, offloadTimestamp, replacePlaceholderText, } from "./library-add.js";
25
+ import { resolvePlaceholder } from "./storage-offload.js";
26
+ /** Frame limits for a local caption run. */
27
+ export const CAPTION_MAX_FRAMES = 4;
28
+ export const CAPTION_MAX_FRAME_BYTES = 3_750_000;
29
+ export const CAPTION_MAX_TOTAL_BYTES = 4_000_000;
30
+ /** Tags kept on an asset after merging suggestions (placeholders are capped at 4KB). */
31
+ const MAX_ASSET_TAGS = 20;
32
+ const FFMPEG_TIMEOUT_MS = 60_000;
33
+ export const CAPTION_TIMEOUT_MS = 120_000;
34
+ export const LOCAL_CAPTION_PROVIDER = "claude";
35
+ const FRAME_EDGE_PX = 1024;
36
+ export const runLocalClaude = (args, options) => captureProviderRun("claude", args, options);
37
+ /** png / jpeg / gif / webp by magic bytes (formats Claude Code can read). */
38
+ export function isCaptionableImage(bytes) {
39
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
40
+ return true;
41
+ }
42
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
43
+ return true;
44
+ if (bytes.length >= 6 && /^GIF8[79]a$/.test(bytes.subarray(0, 6).toString("latin1")))
45
+ return true;
46
+ return (bytes.length >= 12 &&
47
+ bytes.subarray(0, 4).toString("latin1") === "RIFF" &&
48
+ bytes.subarray(8, 12).toString("latin1") === "WEBP");
49
+ }
50
+ /** Keep frames in order while they fit the per-frame and total byte limits. */
51
+ export function fitFrames(frames) {
52
+ const out = [];
53
+ let total = 0;
54
+ for (const frame of frames) {
55
+ if (out.length >= CAPTION_MAX_FRAMES)
56
+ break;
57
+ if (frame.length === 0 || frame.length > CAPTION_MAX_FRAME_BYTES)
58
+ continue;
59
+ if (total + frame.length > CAPTION_MAX_TOTAL_BYTES)
60
+ break;
61
+ out.push(frame);
62
+ total += frame.length;
63
+ }
64
+ return out;
65
+ }
66
+ function asString(value) {
67
+ return typeof value === "string" && value.length > 0 ? value : undefined;
68
+ }
69
+ function captionPrompt(frameNames, mediaKind, title) {
70
+ const what = mediaKind === "video" ? `${frameNames.length} frame(s) sampled from a video` : "an image";
71
+ const files = frameNames.join(", ");
72
+ return [
73
+ `Read these files in the current directory: ${files}. They show ${what}${title ? ` titled "${title}"` : ""}.`,
74
+ "Write a one or two sentence plain description of what it shows, and up to 8 short lowercase tags.",
75
+ 'Reply with only a JSON object: {"description": "...", "tags": ["..."]}',
76
+ ].join("\n");
77
+ }
78
+ /** Pull { description, tags } out of `claude -p --output-format json` output. */
79
+ export function parseLocalCaption(stdout) {
80
+ let text = stdout.trim();
81
+ try {
82
+ const envelope = JSON.parse(text);
83
+ if (envelope.is_error === true)
84
+ return null;
85
+ if (typeof envelope.result === "string")
86
+ text = envelope.result;
87
+ }
88
+ catch {
89
+ // Plain text output: look for the JSON object directly.
90
+ }
91
+ const start = text.indexOf("{");
92
+ const end = text.lastIndexOf("}");
93
+ if (start < 0 || end <= start)
94
+ return null;
95
+ try {
96
+ const body = JSON.parse(text.slice(start, end + 1));
97
+ const description = typeof body.description === "string" ? body.description.trim() : "";
98
+ if (!description)
99
+ return null;
100
+ const tags = Array.isArray(body.tags)
101
+ ? body.tags.filter((tag) => typeof tag === "string" && tag.trim().length > 0).map((tag) => tag.trim())
102
+ : [];
103
+ return { description, tags };
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ /** Map a local Claude Code run to an outcome, with one plain note when skipped. */
110
+ export function captionOutcomeFromRun(run) {
111
+ switch (run.kind) {
112
+ case "not-installed":
113
+ return { kind: "skipped", note: "Captioning skipped: Claude Code is not installed on this machine." };
114
+ case "not-signed-in":
115
+ return { kind: "skipped", note: "Captioning skipped: Claude Code is not signed in (run claude /login)." };
116
+ case "timed-out":
117
+ return { kind: "skipped", note: "Captioning skipped: Claude Code took too long to answer." };
118
+ case "error":
119
+ return { kind: "skipped", note: `Captioning skipped: Claude Code exited with an error (code ${run.code}).` };
120
+ case "ok": {
121
+ const parsed = parseLocalCaption(run.stdout);
122
+ if (!parsed)
123
+ return { kind: "skipped", note: "Captioning skipped: Claude Code's reply was not readable." };
124
+ return { kind: "captioned", reply: { ...parsed, captionedBy: `local:${LOCAL_CAPTION_PROVIDER}` } };
125
+ }
126
+ }
127
+ }
128
+ function frameExtension(bytes) {
129
+ if (bytes[0] === 0x89)
130
+ return ".png";
131
+ if (bytes[0] === 0x47)
132
+ return ".gif";
133
+ if (bytes[0] === 0x52)
134
+ return ".webp";
135
+ return ".jpg";
136
+ }
137
+ /** Extract frames from `file` and caption them with local Claude Code. Never throws. */
138
+ export async function requestCaption(file, request, deps) {
139
+ let frames;
140
+ try {
141
+ frames = fitFrames(await deps.extractFrames(file, request.mediaKind));
142
+ }
143
+ catch (err) {
144
+ return { kind: "skipped", note: `Captioning skipped: could not read frames (${err.message}).` };
145
+ }
146
+ if (frames.length === 0)
147
+ return { kind: "skipped", note: "Captioning skipped: could not read a frame to describe." };
148
+ let dir;
149
+ try {
150
+ dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hq-caption-frames-"));
151
+ const names = [];
152
+ for (const [i, frame] of frames.entries()) {
153
+ const name = `frame-${i + 1}${frameExtension(frame)}`;
154
+ await fs.promises.writeFile(path.join(dir, name), frame, { mode: 0o600 });
155
+ names.push(name);
156
+ }
157
+ const args = [
158
+ "-p",
159
+ captionPrompt(names, request.mediaKind, request.title),
160
+ "--output-format", "json",
161
+ "--tools", "Read",
162
+ "--allowedTools", "Read",
163
+ "--add-dir", dir,
164
+ "--no-session-persistence",
165
+ "--strict-mcp-config",
166
+ "--disable-slash-commands",
167
+ ];
168
+ return captionOutcomeFromRun(await deps.runLocal(args, { cwd: dir, timeoutMs: CAPTION_TIMEOUT_MS }));
169
+ }
170
+ catch (err) {
171
+ return { kind: "skipped", note: `Captioning skipped: ${err.message}` };
172
+ }
173
+ finally {
174
+ if (dir)
175
+ await fs.promises.rm(dir, { recursive: true, force: true });
176
+ }
177
+ }
178
+ /**
179
+ * Merge a caption into an asset. The user's tags stay first and suggested tags
180
+ * follow, deduplicated case-insensitively. A description a person wrote is
181
+ * kept unless `replaceDescription` is set (`hq library describe`).
182
+ */
183
+ export function mergeCaption(asset, reply, options) {
184
+ const tags = [...asset.tags];
185
+ for (const tag of reply.tags) {
186
+ if (tags.length >= MAX_ASSET_TAGS)
187
+ break;
188
+ if (!tags.some((have) => have.toLowerCase() === tag.toLowerCase()))
189
+ tags.push(tag);
190
+ }
191
+ const keepDescription = !options.replaceDescription && asset.captionedBy === "person" && !!asset.description;
192
+ return {
193
+ ...asset,
194
+ tags,
195
+ ...(keepDescription ? {} : { description: reply.description, captionedBy: reply.captionedBy }),
196
+ updatedAt: options.updatedAt,
197
+ };
198
+ }
199
+ /** Re-render a placeholder with a merged caption and replace it atomically. */
200
+ async function writeCaption(file, reply, options) {
201
+ const placeholder = parseCloudPlaceholder(await fs.promises.readFile(file, "utf8"));
202
+ if (!placeholder)
203
+ throw new Error("The placeholder is no longer readable.");
204
+ const before = placeholder.asset ?? { tags: [], media: { kind: options.mediaKind } };
205
+ const asset = mergeCaption(before, reply, {
206
+ replaceDescription: options.replaceDescription,
207
+ updatedAt: offloadTimestamp(options.now),
208
+ });
209
+ await replacePlaceholderText(file, formatCloudPlaceholder({ ...placeholder, asset }));
210
+ return asset;
211
+ }
212
+ /** Spool a stream to a private temp file; the caller removes `dir`. */
213
+ async function spoolToTemp(stream, name) {
214
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hq-caption-"));
215
+ const file = path.join(dir, `source${path.extname(name).toLowerCase()}`);
216
+ await pipeline(stream, fs.createWriteStream(file, { mode: 0o600 }));
217
+ return { dir, file };
218
+ }
219
+ /**
220
+ * Caption one asset `hq library add` just wrote. A URL source is read again
221
+ * into a temp file (it is never written into the tree); a local file is read
222
+ * in place. Never throws.
223
+ */
224
+ export async function captionAddedAsset(request, deps) {
225
+ let spool;
226
+ try {
227
+ let file = request.source.input;
228
+ if (request.source.kind === "url") {
229
+ spool = await spoolToTemp((await deps.openSource(request.source)).stream, request.source.name);
230
+ file = spool.file;
231
+ }
232
+ const outcome = await requestCaption(file, request, deps);
233
+ if (outcome.kind !== "captioned")
234
+ return { status: outcome.kind, note: outcome.note };
235
+ await writeCaption(request.placeholderAbs, outcome.reply, {
236
+ replaceDescription: false,
237
+ now: deps.now(),
238
+ mediaKind: request.mediaKind,
239
+ });
240
+ return { status: "captioned", captionedBy: outcome.reply.captionedBy };
241
+ }
242
+ catch (err) {
243
+ return { status: "failed", note: `The AI caption was not saved: ${err.message}` };
244
+ }
245
+ finally {
246
+ if (spool)
247
+ await fs.promises.rm(spool.dir, { recursive: true, force: true });
248
+ }
249
+ }
250
+ function toPosix(p) {
251
+ return p.split(path.sep).join("/");
252
+ }
253
+ /** Download a company vault object to a temp file, checking size and sha256. */
254
+ async function downloadAsset(companyUid, key, expected, deps) {
255
+ const res = await deps.presign({ company: companyUid, key, op: "get" });
256
+ const body = (await res.json().catch(() => ({})));
257
+ if (!res.ok) {
258
+ throw new Error(`Could not get a download URL: ${asString(body.error) ?? `HTTP ${res.status}`}`);
259
+ }
260
+ const row = (Array.isArray(body.results) ? body.results[0] : body);
261
+ const url = asString(row?.url);
262
+ if (!url)
263
+ throw new Error(`Could not get a download URL: ${asString(row?.error) ?? "no URL returned"}`);
264
+ const spool = await spoolToTemp(await deps.download(url), expected.name);
265
+ try {
266
+ const bytes = await fs.promises.readFile(spool.file);
267
+ const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
268
+ if (bytes.length !== expected.size || (expected.sha256 !== null && sha256 !== expected.sha256)) {
269
+ throw new Error("The downloaded file does not match the placeholder's size and sha256.");
270
+ }
271
+ return spool;
272
+ }
273
+ catch (err) {
274
+ await fs.promises.rm(spool.dir, { recursive: true, force: true });
275
+ throw err;
276
+ }
277
+ }
278
+ /**
279
+ * `hq library describe <placeholder>`: download the asset, caption it again,
280
+ * and replace the description and captioned-by. Suggested tags are added to
281
+ * the existing tags. A caption that does not succeed is an error here.
282
+ */
283
+ export async function runLibraryDescribe(target, deps) {
284
+ const file = resolvePlaceholder(deps.hqRoot, target);
285
+ const placeholderPath = toPosix(path.relative(fs.realpathSync(path.resolve(deps.hqRoot)), file));
286
+ const placeholder = parseCloudPlaceholder(await fs.promises.readFile(file, "utf8"));
287
+ if (!placeholder || placeholder.vault !== "company") {
288
+ throw new Error(`'${placeholderPath}' is not a company library placeholder.`);
289
+ }
290
+ if (placeholder.orphaned)
291
+ throw new Error(`'${placeholderPath}' points at a file that was deleted from the vault.`);
292
+ const slug = /^companies\/([^/]+)\//.exec(placeholderPath)?.[1];
293
+ if (!slug)
294
+ throw new Error(`'${placeholderPath}' is not inside companies/<slug>/.`);
295
+ const name = path.posix.basename(placeholder.path);
296
+ const kind = placeholder.asset?.media?.kind ?? mediaKindFor(contentTypeFor(name));
297
+ if (kind === "other")
298
+ throw new Error(`'${placeholderPath}' is not an image or video, so it cannot be described.`);
299
+ const company = await deps.resolveCompany(slug);
300
+ const spool = await downloadAsset(company.uid, placeholder.key, { size: placeholder.size, sha256: placeholder.sha256, name }, deps);
301
+ try {
302
+ const outcome = await requestCaption(spool.file, { mediaKind: kind, title: placeholder.asset?.title }, deps);
303
+ if (outcome.kind !== "captioned")
304
+ throw new Error(outcome.note);
305
+ const asset = await writeCaption(file, outcome.reply, { replaceDescription: true, now: deps.now(), mediaKind: kind });
306
+ return {
307
+ placeholderPath,
308
+ description: asset.description ?? outcome.reply.description,
309
+ tags: asset.tags,
310
+ captionedBy: outcome.reply.captionedBy,
311
+ };
312
+ }
313
+ finally {
314
+ await fs.promises.rm(spool.dir, { recursive: true, force: true });
315
+ }
316
+ }
317
+ const runTool = (command, args) => new Promise((resolve, reject) => {
318
+ execFile(command, args, { timeout: FFMPEG_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (err, stdout) => {
319
+ if (err) {
320
+ const code = err.code;
321
+ reject(code === "ENOENT" ? new Error(`${command} is not installed`) : err);
322
+ return;
323
+ }
324
+ resolve({ stdout: String(stdout) });
325
+ });
326
+ });
327
+ const SCALE_FILTER = `scale=${FRAME_EDGE_PX}:${FRAME_EDGE_PX}:force_original_aspect_ratio=decrease`;
328
+ /** One JPEG frame of `file` at `seconds` (fast seek: the nearest keyframe). */
329
+ async function jpegFrame(run, file, out, seconds) {
330
+ const seek = seconds !== undefined && seconds > 0 ? ["-ss", seconds.toFixed(3)] : [];
331
+ try {
332
+ await run("ffmpeg", ["-v", "error", "-y", ...seek, "-i", file, "-frames:v", "1", "-vf", SCALE_FILTER, "-q:v", "4", out]);
333
+ const bytes = await fs.promises.readFile(out);
334
+ return bytes.length > 0 ? bytes : null;
335
+ }
336
+ catch (err) {
337
+ if (err.message.endsWith("is not installed"))
338
+ throw err;
339
+ return null;
340
+ }
341
+ }
342
+ /** Seek points at 10%, 35%, 60% and 85% of a known duration. */
343
+ export function videoSeekPoints(durationSeconds) {
344
+ if (durationSeconds === null || !Number.isFinite(durationSeconds) || durationSeconds <= 0)
345
+ return [];
346
+ return [0.1, 0.35, 0.6, 0.85].map((share) => durationSeconds * share);
347
+ }
348
+ /** Frames for captioning: the image itself when it fits, otherwise ffmpeg JPEGs. */
349
+ export async function extractCaptionFrames(file, mediaKind, run = runTool) {
350
+ if (mediaKind === "image") {
351
+ const stat = await fs.promises.stat(file);
352
+ if (stat.size <= CAPTION_MAX_FRAME_BYTES) {
353
+ const bytes = await fs.promises.readFile(file);
354
+ if (isCaptionableImage(bytes))
355
+ return [bytes];
356
+ }
357
+ }
358
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hq-frames-"));
359
+ try {
360
+ if (mediaKind === "image") {
361
+ const frame = await jpegFrame(run, file, path.join(dir, "image.jpg"));
362
+ return frame ? [frame] : [];
363
+ }
364
+ let duration = null;
365
+ try {
366
+ const { stdout } = await run("ffprobe", [
367
+ "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", file,
368
+ ]);
369
+ const parsed = Number.parseFloat(stdout.trim());
370
+ duration = Number.isFinite(parsed) ? parsed : null;
371
+ }
372
+ catch {
373
+ // No duration: fall back to the first frame.
374
+ }
375
+ const frames = [];
376
+ const points = videoSeekPoints(duration);
377
+ for (const [i, seconds] of points.entries()) {
378
+ const frame = await jpegFrame(run, file, path.join(dir, `frame-${i}.jpg`), seconds);
379
+ if (frame)
380
+ frames.push(frame);
381
+ }
382
+ if (frames.length === 0) {
383
+ const first = await jpegFrame(run, file, path.join(dir, "first.jpg"));
384
+ if (first)
385
+ frames.push(first);
386
+ }
387
+ return frames;
388
+ }
389
+ finally {
390
+ await fs.promises.rm(dir, { recursive: true, force: true });
391
+ }
392
+ }
393
+ //# sourceMappingURL=library-caption.js.map
@@ -0,0 +1,32 @@
1
+ /** Default and cap for --expires, in minutes (hq-pro caps presign at 3600 s). */
2
+ export declare const LIBRARY_LINK_DEFAULT_MINUTES = 15;
3
+ export declare const LIBRARY_LINK_MAX_MINUTES = 60;
4
+ export interface LibraryLinkOptions {
5
+ /** cmp_ uid, or a slug on a Cognito session. */
6
+ company?: string;
7
+ /** Link lifetime in minutes (1..60). */
8
+ expiresMinutes?: number;
9
+ }
10
+ export interface LibraryLinkDeps {
11
+ /** HQ root; only called for a placeholder target. */
12
+ hqRoot: () => string;
13
+ /** True when the call runs under HQ_API_KEY (slugs cannot be resolved). */
14
+ apiKey: boolean;
15
+ /** Company ref (slug or uid, or undefined for the default) → cmp_ uid. */
16
+ resolveCompany: (ref: string | undefined) => Promise<string>;
17
+ /** POST /v1/files/presign. */
18
+ presign: (body: Record<string, unknown>) => Promise<Response>;
19
+ }
20
+ export interface LibraryLinkResult {
21
+ key: string;
22
+ company: string;
23
+ placeholderPath?: string;
24
+ url: string;
25
+ expiresIn: number;
26
+ expiresAt?: string;
27
+ }
28
+ /** Validate --expires minutes; returns seconds for the presign body. */
29
+ export declare function linkExpirySeconds(minutes: number | undefined): number;
30
+ export declare function isVaultKey(target: string): boolean;
31
+ export declare function runLibraryLink(target: string, options: LibraryLinkOptions, deps: LibraryLinkDeps): Promise<LibraryLinkResult>;
32
+ //# sourceMappingURL=library-link.d.ts.map
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `hq library link <placeholder|vault-key>` (US-009) — mint a short-lived
3
+ * download link for a library asset, for handing to a tool that fetches by URL
4
+ * (for example the Meta Marketing API's adimages / advideos upload by URL).
5
+ *
6
+ * Runs on a Cognito session or on an `HQ_API_KEY` with `files:read` (the
7
+ * library-agent preset). Under a key, vaultApiFetch rewrites /v1/files/presign
8
+ * to /v1/keys/files/presign; the key is company-bound, so the company comes
9
+ * from --company <cmp_uid>, HQ_COMPANY_UID, or the key's own membership.
10
+ *
11
+ * Wire contract (hq-pro files-presign):
12
+ * POST /v1/files/presign
13
+ * body { company: <cmp_uid>, key, op: "get", expiresIn: <seconds> }
14
+ * 200 { results: [{ key, op, url, expiresIn, expiresAt } | { key, op, error, code }] }
15
+ *
16
+ * The URL is a bearer credential until it expires. It goes to stdout only and
17
+ * never into an error message.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { parseCloudPlaceholder } from "@indigoai-us/hq-cloud";
22
+ import { resolvePlaceholder } from "./storage-offload.js";
23
+ /** Default and cap for --expires, in minutes (hq-pro caps presign at 3600 s). */
24
+ export const LIBRARY_LINK_DEFAULT_MINUTES = 15;
25
+ export const LIBRARY_LINK_MAX_MINUTES = 60;
26
+ /** Vault keys written by `hq library add` / `hq storage offload`. */
27
+ const VAULT_KEY_PREFIX = ".hq-offload/";
28
+ function asString(value) {
29
+ return typeof value === "string" && value.length > 0 ? value : undefined;
30
+ }
31
+ /** Validate --expires minutes; returns seconds for the presign body. */
32
+ export function linkExpirySeconds(minutes) {
33
+ const value = minutes ?? LIBRARY_LINK_DEFAULT_MINUTES;
34
+ if (!Number.isInteger(value) || value < 1 || value > LIBRARY_LINK_MAX_MINUTES) {
35
+ throw new Error(`--expires must be a whole number of minutes from 1 to ${LIBRARY_LINK_MAX_MINUTES}.`);
36
+ }
37
+ return value * 60;
38
+ }
39
+ export function isVaultKey(target) {
40
+ return target.startsWith(VAULT_KEY_PREFIX);
41
+ }
42
+ export async function runLibraryLink(target, options, deps) {
43
+ const expiresIn = linkExpirySeconds(options.expiresMinutes);
44
+ let key;
45
+ let placeholderPath;
46
+ let companyRef = options.company;
47
+ if (isVaultKey(target)) {
48
+ key = target;
49
+ }
50
+ else {
51
+ const hqRoot = deps.hqRoot();
52
+ const file = resolvePlaceholder(hqRoot, target);
53
+ placeholderPath = path
54
+ .relative(fs.realpathSync(path.resolve(hqRoot)), file)
55
+ .split(path.sep)
56
+ .join("/");
57
+ const placeholder = parseCloudPlaceholder(await fs.promises.readFile(file, "utf8"));
58
+ if (!placeholder || placeholder.vault !== "company") {
59
+ throw new Error(`'${placeholderPath}' is not a company library placeholder.`);
60
+ }
61
+ if (placeholder.orphaned) {
62
+ throw new Error(`'${placeholderPath}' points at a file that was deleted from the vault.`);
63
+ }
64
+ key = placeholder.key;
65
+ // On a session the placeholder's companies/<slug>/ names the company. A
66
+ // key is company-bound and cannot resolve slugs, so it uses its own.
67
+ if (companyRef === undefined && !deps.apiKey) {
68
+ companyRef = /^companies\/([^/]+)\//.exec(placeholderPath)?.[1];
69
+ if (!companyRef)
70
+ throw new Error(`'${placeholderPath}' is not inside companies/<slug>/; pass --company.`);
71
+ }
72
+ }
73
+ const company = await deps.resolveCompany(companyRef);
74
+ const res = await deps.presign({ company, key, op: "get", expiresIn });
75
+ const body = (await res.json().catch(() => ({})));
76
+ if (!res.ok) {
77
+ throw new Error(`Could not mint a download link: ${asString(body.error) ?? asString(body.message) ?? `HTTP ${res.status}`}`);
78
+ }
79
+ const row = (Array.isArray(body.results) ? body.results[0] : undefined);
80
+ const url = asString(row?.url);
81
+ if (!url) {
82
+ throw new Error(`Could not mint a download link for ${key}: ${asString(row?.error) ?? "no URL returned"}`);
83
+ }
84
+ return {
85
+ key,
86
+ company,
87
+ ...(placeholderPath ? { placeholderPath } : {}),
88
+ url,
89
+ expiresIn: typeof row?.expiresIn === "number" ? row.expiresIn : expiresIn,
90
+ ...(asString(row?.expiresAt) ?? asString(body.expiresAt)
91
+ ? { expiresAt: asString(row?.expiresAt) ?? asString(body.expiresAt) }
92
+ : {}),
93
+ };
94
+ }
95
+ //# sourceMappingURL=library-link.js.map
@@ -0,0 +1,72 @@
1
+ import { Command } from "commander";
2
+ import { type CloudPlaceholder } from "@indigoai-us/hq-cloud";
3
+ import { type SearchCollection } from "../lib/search-index/index.js";
4
+ import { type LibraryAddResult } from "./library-add.js";
5
+ /** Mirrors hq-pro's MAX_ASSET_SEARCH_TAGS / tag length bound. */
6
+ export declare const MAX_LIBRARY_SEARCH_TAGS = 20;
7
+ export interface LibrarySearchOptions {
8
+ tags: string[];
9
+ company?: string;
10
+ count?: number;
11
+ }
12
+ export interface LibraryAssetHit {
13
+ placeholderPath: string;
14
+ originalPath?: string;
15
+ title: string;
16
+ tags: string[];
17
+ mediaKind?: string;
18
+ vaultKey?: string;
19
+ snippet?: string;
20
+ }
21
+ export interface LibrarySearchResult {
22
+ source: "cloud" | "local";
23
+ /** Set on a local result: the transport code that forced the fallback. */
24
+ fallbackReason?: string;
25
+ results: LibraryAssetHit[];
26
+ }
27
+ export interface LibrarySearchDeps {
28
+ getToken: () => Promise<string>;
29
+ post: (token: string, body: Record<string, unknown>) => Promise<Response>;
30
+ searchLocal: (query: string, options: LibrarySearchOptions) => LibraryAssetHit[];
31
+ }
32
+ /** Split repeatable / comma-listed `--tag` values into a validated tag list. */
33
+ export declare function normalizeTags(values: readonly string[]): string[];
34
+ /** Case-insensitive: every requested tag must be on the asset (hq-pro assetHasTags). */
35
+ export declare function hasAllTags(assetTags: readonly string[], wanted: readonly string[]): boolean;
36
+ /** Map one /v1/search result to a hit; null when it carries no asset metadata. */
37
+ export declare function hitFromCloudResult(result: unknown): LibraryAssetHit | null;
38
+ /**
39
+ * Testable core. Cloud search first; a transport failure (offline, DNS, reset)
40
+ * falls back to the local qmd index. HTTP errors are not a fallback: a 401 or
41
+ * 400 is reported, since the local index cannot answer for the vault.
42
+ */
43
+ export declare function runLibrarySearch(query: string, options: LibrarySearchOptions, deps: LibrarySearchDeps): Promise<LibrarySearchResult>;
44
+ /** A local hit, or null for a placeholder that is not a company library asset. */
45
+ export declare function hitFromPlaceholder(placeholder: CloudPlaceholder | null, tags: readonly string[], snippet?: string): LibraryAssetHit | null;
46
+ /** Asset collections to search, narrowed to one company when asked. */
47
+ export declare function assetCollections(collections: readonly SearchCollection[], company?: string): SearchCollection[];
48
+ export interface LocalSearchEnv {
49
+ collections: readonly SearchCollection[];
50
+ runQmd: (args: string[]) => {
51
+ stdout: string;
52
+ stderr: string;
53
+ };
54
+ readFile: (file: string) => string | null;
55
+ listPlaceholders: (directory: string) => string[];
56
+ }
57
+ /**
58
+ * Search the local qmd `<co>-assets` collections. With a query, qmd ranks
59
+ * hits and `--full` returns each placeholder's text (qmd normalises file
60
+ * paths, so the text, not the path, is the reliable record). Without a query
61
+ * (tags only), the collection directories are listed and filtered by tag.
62
+ */
63
+ export declare function searchLocalAssets(query: string, options: LibrarySearchOptions, env: LocalSearchEnv): LibraryAssetHit[];
64
+ /**
65
+ * `hq library ls`: every company library placeholder under `directory`
66
+ * (HQ-relative paths), filtered by tag. Sorted by placeholder path.
67
+ */
68
+ export declare function listLibraryAssets(hqRoot: string, directory: string, tags: readonly string[], env?: Pick<LocalSearchEnv, "readFile" | "listPlaceholders">): LibraryAssetHit[];
69
+ export declare function formatLibraryResult(result: LibrarySearchResult): string;
70
+ export declare function formatLibraryAddResult(result: LibraryAddResult): string;
71
+ export declare function registerLibraryCommand(program: Command): void;
72
+ //# sourceMappingURL=library.d.ts.map