videobook 0.2.5 → 0.3.1

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 (98) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +6 -2
  3. package/dist/archive-b2.js +1 -1
  4. package/dist/asset-media-projection.js +161 -0
  5. package/dist/cli/args.js +42 -3
  6. package/dist/cli/config-command.js +17 -1
  7. package/dist/cli/doctor-command.js +15 -0
  8. package/dist/cli/har-command.js +86 -0
  9. package/dist/cli/main.js +13 -1
  10. package/dist/command-reference.js +3 -1
  11. package/dist/content-store.js +2 -1
  12. package/dist/diagnostics.js +2 -2
  13. package/dist/env-secrets.js +1 -0
  14. package/dist/generation-har.js +106 -0
  15. package/dist/generation-reference-mentions.js +19 -0
  16. package/dist/goal-plan-v3.js +11 -1
  17. package/dist/goal-plan.js +1 -1
  18. package/dist/goal-store.js +2 -2
  19. package/dist/har-capture.js +175 -0
  20. package/dist/har-export.js +238 -0
  21. package/dist/har-index.js +98 -0
  22. package/dist/har-retention.js +73 -0
  23. package/dist/har-settings.js +27 -0
  24. package/dist/jobs/_alibaba-upload.js +2 -4
  25. package/dist/jobs/_fal-upload.js +2 -4
  26. package/dist/jobs/_image-edit-adapter.js +3 -1
  27. package/dist/jobs/_kie-upload.js +2 -4
  28. package/dist/jobs/assess-media-probes.js +94 -0
  29. package/dist/jobs/generate-media.js +10 -1
  30. package/dist/jobs/image-subjects.js +3 -1
  31. package/dist/jobs/kie-paygate.js +10 -3
  32. package/dist/jobs/register-all.js +2 -0
  33. package/dist/jobs/run-notebook.js +20 -2
  34. package/dist/jobs/xai-paygate.js +5 -2
  35. package/dist/library-asset-projection.js +141 -0
  36. package/dist/lightning-defaults.js +4 -4
  37. package/dist/media/adapters/kie.js +2 -1
  38. package/dist/media/catalog.js +8 -2
  39. package/dist/media/cost.js +1 -1
  40. package/dist/media/descriptor.js +7 -4
  41. package/dist/media/frontier-gaps.js +0 -1
  42. package/dist/media/har-endpoint.js +25 -0
  43. package/dist/media/json-schema.js +3 -0
  44. package/dist/media/preferred-defaults.js +161 -0
  45. package/dist/media/prompt-dialects.js +9 -1
  46. package/dist/media-library.js +158 -153
  47. package/dist/notebook-sticky-settings.js +60 -0
  48. package/dist/package-version.js +18 -0
  49. package/dist/posts/dropbox-auth.js +1 -1
  50. package/dist/posts/post-metrics.js +2 -1
  51. package/dist/provider-usage.js +1 -18
  52. package/dist/queue.js +12 -1
  53. package/dist/serve.js +2 -0
  54. package/dist/tools/api-status.js +1 -1
  55. package/dist/tools/generate-media.js +3 -0
  56. package/dist/tools/generation-payload.js +17 -2
  57. package/dist/tools/get-generation-har.js +43 -0
  58. package/dist/tools/index.js +4 -0
  59. package/dist/tools/list-cell-generations.js +19 -0
  60. package/dist/tools/reconcile-project-work.js +52 -193
  61. package/dist/tools/set-har-capture.js +30 -0
  62. package/dist/tools/videobook-domain.js +16 -1
  63. package/dist/upload.js +2 -1
  64. package/dist/usage-providers.js +48 -10
  65. package/dist/workspace-action-groups.js +2 -2
  66. package/dist/x/account.js +1 -1
  67. package/dist/x/authenticated-handle.js +1 -1
  68. package/dist/x/oauth-flow.js +1 -1
  69. package/dist/x/post-to-x.js +2 -1
  70. package/dist/x/x-tokens.js +2 -1
  71. package/package.json +15 -14
  72. package/ui/assets/{alive-captions-duTKsyll.js → alive-captions-CnLK22ED.js} +1 -1
  73. package/ui/assets/confirm-bodies-BaalBxp1.js +1 -0
  74. package/ui/assets/{fullscreen-COrmiHPn.js → fullscreen-DTJQJ6dX.js} +1 -1
  75. package/ui/assets/{goal-ui-D5Xsm2EY.js → goal-ui-CN-kZTEO.js} +2 -2
  76. package/ui/assets/index-CTPstLYq.css +1 -0
  77. package/ui/assets/index-Dp81yChE.js +37 -0
  78. package/ui/assets/inspector-jeyma7HL.js +1 -0
  79. package/ui/assets/{jobs-ui-DdP2Re41.js → jobs-ui-D6S3LJ4j.js} +1 -1
  80. package/ui/assets/{music-video-B709JZqa.js → music-video-CDqr0X6G.js} +1 -1
  81. package/ui/assets/ui-BBM3YCY6.css +1 -0
  82. package/ui/assets/ui-Cqs1-YNg.js +2 -0
  83. package/ui/assets/vendor-BWf-WCsY.js +19 -0
  84. package/ui/assets/{vendor-flow-CKiuzaeM.js → vendor-flow-DPvi8ynA.js} +1 -1
  85. package/ui/assets/vendor-icons-D3KrGMJY.js +1 -0
  86. package/ui/assets/vendor-mcp-BSR9acpG.js +60 -0
  87. package/ui/assets/videobook-b9VODNOA.js +21 -0
  88. package/ui/index.html +14 -14
  89. package/ui/assets/confirm-bodies--2sc9DbH.js +0 -1
  90. package/ui/assets/index-DEKl00St.js +0 -37
  91. package/ui/assets/index-DWfR79lA.css +0 -1
  92. package/ui/assets/inspector-EBp3IhvH.js +0 -1
  93. package/ui/assets/ui-Oirr9VOf.css +0 -1
  94. package/ui/assets/ui-jRPWsNUw.js +0 -2
  95. package/ui/assets/vendor-BFupLz37.js +0 -19
  96. package/ui/assets/vendor-icons-CGqhCIXV.js +0 -1
  97. package/ui/assets/vendor-mcp-DQEeVZH0.js +0 -67
  98. package/ui/assets/videobook-0DOLlhG2.js +0 -20
package/CHANGELOG.md CHANGED
@@ -6,6 +6,102 @@ and the package uses [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.1] - 2026-10-25
10
+
11
+ ### Added
12
+
13
+ - KIE Kling 4.0 reference-to-video and keyframe-to-video, 3 to 30 seconds at
14
+ 720p or 1080p. Reference-to-video takes up to ten images and five videos,
15
+ and generates from the prompt alone when none are connected. Keyframe-to-video
16
+ takes a first frame, up to eight middle keyframes and a last frame, as many
17
+ as the duration allows. Prompts name connected media the way Kling 4.0 does:
18
+ `@image_1`, `@video_1`, `@first_frame`, `@keyframe_1` and `@last_frame`.
19
+ - KIE PixVerse V6 Max text-to-video and image-to-video, 1 to 15 seconds at
20
+ 360p to 1080p, with optional generated audio and a seed. Image-to-video
21
+ follows the image's aspect ratio.
22
+ - KIE OmniHuman 2.0, which animates one image to a driving audio track at 720p
23
+ for as long as the audio runs, with an optional prompt of up to 300
24
+ characters. KIE's watermark stays on by default (`enable_watermark`).
25
+ - The KIE Grok Imagine Video 1.5 preview, the KIE Grok Imagine endpoint that
26
+ takes first and last frames, a loop image (`loop_url`) and up to four timed
27
+ `keyframes`. It shares the Grok Imagine Video 1.5 image-to-video frontier
28
+ ranking. Kling 4.0, PixVerse V6 Max and OmniHuman 2.0 are on no top-10 board
29
+ and appear in the unranked group.
30
+
31
+ ### Changed
32
+
33
+ - Upgrade all `@apicity/*` dependencies to 0.18.1, which prices the new KIE
34
+ models. Its endpoint catalog now lists fal `v1.queue.status` and
35
+ `v1.queue.result`, with the other fal SDK methods written as method
36
+ shorthand, so `apicity har report` from `@apicity/cli` 0.18.1 can resolve the
37
+ fal polling captures Videobook writes. The requests sent to fal are
38
+ unchanged.
39
+ - Image and video models default to a 12-second duration (or the nearest
40
+ allowed length), a 9:16 aspect ratio and their highest resolution, quality
41
+ and bitrate. Each notebook remembers the last duration, aspect ratio,
42
+ resolution and quality a cell used, and applies them to new cells and model
43
+ switches. Goals saved before this change keep their 2K scene; new goals use
44
+ 4K.
45
+ - On desktop, Settings shows AI providers in one column and accounts, storage
46
+ and diagnostics in another; on phones the columns stack behind section jump
47
+ buttons. The chat input stays a plain bar on Settings.
48
+ - The Library opens from an Assets button in the notebook toolbar instead of
49
+ the header, and Arrange is renamed Clean.
50
+ - Library browsing pages assets before building their rows and no longer runs
51
+ ffprobe while listing; proxy checks and duration backfills run as background
52
+ jobs.
53
+ - A Post can take its published link without being saved to iPhone or Dropbox
54
+ first.
55
+
56
+ ### Fixed
57
+
58
+ - HAR captures of KIE's nested Veo and Suno SDK calls are named by their
59
+ apicity catalog dot paths, such as `api.v1.veo.generate` and
60
+ `api.v1.generate.sounds`, instead of their SDK paths, so `apicity har report`
61
+ resolves them. Sub-provider prefixes that are URL segments stay:
62
+ `kie.claude.post.v1.messages` is named `claude.v1.messages`.
63
+ - A generation that fails before reaching its provider, such as a request the
64
+ schema refuses, is captured under the provider's catalog endpoint, and fal,
65
+ Alibaba and KIE file uploads are captured as `storage.upload.initiate`,
66
+ `api.v1.uploads` and `api.fileStreamUpload`, so `apicity har report` resolves
67
+ them.
68
+ - A notebook generation whose provider fails after accepting the request now
69
+ marks its plan failed with the provider error. `list_notebook_generation_plans`
70
+ and the Jobs screen kept reporting such plans as awaiting output.
71
+ - v3 goals saved with a 2K scene load again and keep that resolution when they
72
+ are edited, approved or retried.
73
+ - Image subject edits check the provider key before uploading the source image,
74
+ so a missing or placeholder key no longer sends the image to a public file
75
+ host for a request that cannot run.
76
+ - Settings loads when the page opens on it, or when it opens before the MCP
77
+ connection is ready.
78
+ - The multyculty workflow and video poster recovery tests no longer time out on
79
+ loaded hosts, and the chat-composer browser test no longer reads the runner's
80
+ `.env` or provider keys and matches the ranked model picker.
81
+
82
+ ## [0.3.0] - 2026-10-24
83
+
84
+ ### Added
85
+
86
+ - Opt-in local provider HAR diagnostics with Settings, MCP and CLI controls, concurrent book attribution and retention limits.
87
+ Captured SDK calls are named by their apicity catalog dot path, such as
88
+ `api.v1.jobs.createTask`, so `apicity har report` attributes them to their
89
+ endpoints.
90
+ - The Request payload panel of a queued, generating or failed cell downloads
91
+ the attempt's scrubbed captures as one HAR file, or shows "Capture off" with
92
+ a link to Settings. `inspect_generation_request` reports whether an asset has
93
+ a capture, with its path and outcome; the new `get_generation_har` tool
94
+ returns the HAR for an asset or attempt id.
95
+ - `videobook har list` and `videobook har export --out <dir|file.tar.gz>`,
96
+ both with `--since`, `--failures` and `--provider`. Export re-runs the
97
+ `@apicity/har` scrubber on every capture and refuses, naming the file, when
98
+ scrubbing would change it. Otherwise it writes the captures with an index
99
+ and a manifest and prints the `apicity har report` hand-off.
100
+
101
+ ### Changed
102
+
103
+ - Upgrade all Apicity dependencies to 0.18.0.
104
+
9
105
  ## [0.2.5] - 2026-10-24
10
106
 
11
107
  ### Added
package/README.md CHANGED
@@ -65,6 +65,9 @@ videobook gc-books # reclaim catalog storage in every book
65
65
  videobook doctor # check Node, binaries, native modules and keys
66
66
  videobook config # show where each provider key comes from
67
67
  videobook config set FAL_API_KEY op://Private/FAL_API_KEY/password
68
+ videobook config set har_capture failures # opt in to provider HAR capture
69
+ videobook har list --failures # captured provider calls, newest first
70
+ videobook har export --out captures.tar.gz # scrub-checked captures for apicity
68
71
  videobook setup 1password --op-vault Private
69
72
  videobook --version
70
73
  ```
@@ -104,10 +107,11 @@ run while a server owns the library.
104
107
  Installed data lives in `~/.videobook`:
105
108
 
106
109
  - `books/`, the library of books
107
- - `app-state.json`, connected X and Dropbox accounts
110
+ - `app-state.json`, connected X and Dropbox accounts and the HAR capture setting
108
111
  - `.env`, provider keys, also editable from Settings in the web UI
112
+ - `har/`, opt-in provider HAR captures (off by default) with an `index.jsonl`
109
113
 
110
- `VIDEOBOOK_HOME` or `--home` moves all three. `VIDEOBOOK_LIBRARY_DIR`,
114
+ `VIDEOBOOK_HOME` or `--home` moves all four. `VIDEOBOOK_LIBRARY_DIR`,
111
115
  `VIDEOBOOK_APP_STATE_PATH` and `VIDEOBOOK_ENV_PATH` move one each; `--home`
112
116
  overrides any of them inherited from your shell. Set `VIDEOBOOK_HOME` and
113
117
  `VIDEOBOOK_ENV_PATH` in the environment, not in `.env`, because they decide
@@ -1,4 +1,4 @@
1
- import { createB2 } from "@apicity/b2";
1
+ import { createB2 } from "./usage-providers.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { createReadStream } from "node:fs";
4
4
  import { open, readFile, stat } from "node:fs/promises";
@@ -0,0 +1,161 @@
1
+ import { createHash } from "node:crypto";
2
+ import { findAudioFile, findPrimaryMediaFile, findVideoFile } from "videobook-engine";
3
+ import { logger } from "./logger.js";
4
+ import { queue } from "./queue.js";
5
+ import { scheduleMediaDurationBackfill, scheduleVideoProxyAssessment, } from "./jobs/assess-media-probes.js";
6
+ import { videoProxyAssessmentMatches, videoProxyMetadataMatches, } from "./video-proxy.js";
7
+ export const PARTIAL_DOWNLOAD_RE = /\.(mp4|mov)\.part$/i;
8
+ const DURATION_PROBE_EXT_RE = /\.(mp4|mov|webm|mkv|mp3|wav|m4a|aac|ogg|flac)$/i;
9
+ const IMAGE_FILE_RE = /\.(jpg|jpeg|png|webp|gif)$/i;
10
+ export function findAssetMediaFile(files, asset) {
11
+ return asset.kind === "audio"
12
+ ? findAudioFile(files, asset.kind)
13
+ : asset.kind === "video"
14
+ ? findVideoFile(files.filter((file) => file.name !== "proxy.mp4"))
15
+ : findPrimaryMediaFile(files, asset.kind);
16
+ }
17
+ // Content fingerprint for cache-busting media URLs. Pure function of the
18
+ // manifest tuples; partial downloads are excluded because their size grows on
19
+ // every poll while no served URL points at them.
20
+ function computeAssetFingerprint(files) {
21
+ const settled = files.filter((f) => !PARTIAL_DOWNLOAD_RE.test(f.name));
22
+ if (settled.length === 0)
23
+ return "";
24
+ const sorted = [...settled].sort((a, b) => a.name.localeCompare(b.name));
25
+ const hash = createHash("sha1");
26
+ for (const f of sorted) {
27
+ hash.update(f.name);
28
+ hash.update("\0");
29
+ hash.update(String(f.sizeBytes ?? 0));
30
+ hash.update("\0");
31
+ hash.update(f.objectHash);
32
+ hash.update("\0");
33
+ }
34
+ return hash.digest("hex").slice(0, 12);
35
+ }
36
+ export function indexByArtifactId(rows) {
37
+ const map = new Map();
38
+ for (const row of rows)
39
+ map.set(row.artifactId, row);
40
+ return map;
41
+ }
42
+ export function groupByArtifactId(rows) {
43
+ const map = new Map();
44
+ for (const row of rows) {
45
+ const list = map.get(row.artifactId);
46
+ if (list)
47
+ list.push(row);
48
+ else
49
+ map.set(row.artifactId, [row]);
50
+ }
51
+ return map;
52
+ }
53
+ function streamSeconds(stream) {
54
+ return stream.durationTicks * stream.timeBase.numerator / stream.timeBase.denominator;
55
+ }
56
+ export function assetStreamProfile(streams, meta) {
57
+ const videoStream = streams.find((stream) => stream.kind === "video" && stream.video);
58
+ const audioStream = streams.find((stream) => stream.kind === "audio" && stream.audio);
59
+ const frameRate = videoStream?.video?.averageFrameRate
60
+ ?? videoStream?.video?.nominalFrameRate;
61
+ const streamDuration = videoStream ? streamSeconds(videoStream) : 0;
62
+ const metadataDuration = Number(meta?.duration);
63
+ const sourceDuration = streamDuration > 0
64
+ ? streamDuration
65
+ : Number.isFinite(metadataDuration) ? metadataDuration : 0;
66
+ const sourceProfile = sourceDuration > 0
67
+ ? {
68
+ videoDuration: sourceDuration,
69
+ avgFps: frameRate ? frameRate.numerator / frameRate.denominator : null,
70
+ hasAudio: audioStream !== undefined || meta?.hasAudio === true,
71
+ }
72
+ : null;
73
+ return { videoStream, audioStream, frameRate, sourceProfile };
74
+ }
75
+ function isActiveWork(work) {
76
+ return work !== null && (work.status === "pending" || work.status === "working");
77
+ }
78
+ export function servedVideoProxyFile(input) {
79
+ if (input.artifact.kind !== "video" || !input.mediaFile)
80
+ return null;
81
+ const proxyFile = input.files.find((file) => file.name === "proxy.mp4");
82
+ if (!proxyFile)
83
+ return null;
84
+ const metadata = input.proxyMetadata;
85
+ if (metadata.ok && videoProxyMetadataMatches(metadata.value, input.mediaFile, proxyFile)) {
86
+ return proxyFile;
87
+ }
88
+ const assessed = metadata.ok
89
+ && videoProxyAssessmentMatches(metadata.value, input.mediaFile, proxyFile);
90
+ if (!assessed && !isActiveWork(input.work) && input.sourceProfile) {
91
+ scheduleVideoProxyAssessment({
92
+ bookId: input.bookId,
93
+ artifactId: input.artifact.artifactId,
94
+ sourceFile: input.mediaFile,
95
+ proxyFile,
96
+ sourceProfile: input.sourceProfile,
97
+ });
98
+ }
99
+ return null;
100
+ }
101
+ export function assetDuration(input) {
102
+ const stored = input.meta?.duration;
103
+ if (typeof stored === "number")
104
+ return stored;
105
+ const stream = input.profile.videoStream ?? input.profile.audioStream;
106
+ const measured = stream ? streamSeconds(stream) : 0;
107
+ if (measured > 0)
108
+ return measured;
109
+ if (input.meta && input.mediaFile && DURATION_PROBE_EXT_RE.test(input.mediaFile.name)) {
110
+ scheduleMediaDurationBackfill({
111
+ bookId: input.bookId,
112
+ artifactId: input.artifactId,
113
+ mediaFile: input.mediaFile,
114
+ });
115
+ }
116
+ return null;
117
+ }
118
+ function mediaCacheBust(kind, files, mediaFile) {
119
+ if (kind === "video" || kind === "image") {
120
+ const fingerprint = computeAssetFingerprint(files);
121
+ return fingerprint ? `?v=${fingerprint}` : "";
122
+ }
123
+ const mediaHash = mediaFile?.objectHash;
124
+ return mediaHash ? `?v=${mediaHash.slice(0, 12)}` : "";
125
+ }
126
+ export function assetMediaUrls(input) {
127
+ const { artifact, files } = input;
128
+ const base = `/files/${input.bookId}/${artifact.artifactId}`;
129
+ const cacheBust = mediaCacheBust(artifact.kind, files, input.mediaFile);
130
+ const characterImage = artifact.kind === "character"
131
+ ? files.find((file) => IMAGE_FILE_RE.test(file.name)) ?? null
132
+ : null;
133
+ const served = characterImage ?? input.proxyFile ?? input.mediaFile;
134
+ const media_url = served ? `${base}/${served.name}${cacheBust}` : null;
135
+ if (characterImage)
136
+ return { media_url, thumbnail_url: media_url };
137
+ const hasThumbnail = files.some((file) => file.name === "original.jpg");
138
+ const thumbnail_url = hasThumbnail
139
+ ? `${base}/original.jpg${cacheBust}`
140
+ : artifact.kind === "image" ? media_url : null;
141
+ return { media_url, thumbnail_url };
142
+ }
143
+ export async function scheduleMissingVideoPoster(engine, input) {
144
+ const { artifact, mediaFile } = input;
145
+ if (artifact.kind !== "video" || input.status !== "ready" || !mediaFile)
146
+ return;
147
+ if (input.files.some((file) => file.name === "original.jpg"))
148
+ return;
149
+ const recovery = await engine.metadata.artifacts.read(artifact.artifactId, "poster-recovery");
150
+ if (recovery.ok && recovery.value.failed && recovery.value.sourceHash === mediaFile.objectHash)
151
+ return;
152
+ void queue.enqueue(`extract-video-poster-${input.bookId}-${artifact.artifactId}-${mediaFile.objectHash}`, {
153
+ type: "extract_video_poster",
154
+ bookId: input.bookId,
155
+ posterArtifactId: artifact.artifactId,
156
+ sourceFilename: mediaFile.name,
157
+ sourceHash: mediaFile.objectHash,
158
+ }).catch((error) => {
159
+ logger.error("poster-enqueue-failed", { assetId: artifact.artifactId, error });
160
+ });
161
+ }
package/dist/cli/args.js CHANGED
@@ -7,9 +7,10 @@ export class CliUsageError extends Error {
7
7
  }
8
8
  }
9
9
  const LOG_LEVELS = new Set(["silent", "error", "warn", "info", "debug"]);
10
- const COMMANDS = new Set(["serve", "mcp", "run", "tools", "gc-books", "doctor", "setup", "config", "version", "help"]);
10
+ const COMMANDS = new Set(["serve", "mcp", "run", "tools", "gc-books", "doctor", "setup", "config", "har", "version", "help"]);
11
11
  const CONFIG_ACTIONS = new Set(["list", "get", "set", "unset"]);
12
- const POSITIONAL_COMMANDS = new Set(["run", "setup", "config"]);
12
+ const HAR_ACTIONS = new Set(["list", "export"]);
13
+ const POSITIONAL_COMMANDS = new Set(["run", "setup", "config", "har"]);
13
14
  const DEFAULT_RUN_TIMEOUT_SECONDS = 600;
14
15
  const GLOBAL_OPTIONS = {
15
16
  port: { type: "string" },
@@ -28,6 +29,7 @@ const COMMAND_OPTIONS = {
28
29
  doctor: { json: { type: "boolean" } },
29
30
  setup: { "op-vault": { type: "string" }, "op-token": { type: "string" }, "no-verify": { type: "boolean" }, remove: { type: "boolean" } },
30
31
  config: { json: { type: "boolean" } },
32
+ har: { out: { type: "string" }, since: { type: "string" }, failures: { type: "boolean" }, provider: { type: "string" }, json: { type: "boolean" } },
31
33
  };
32
34
  function splitCommand(argv) {
33
35
  const [first, ...rest] = argv;
@@ -147,6 +149,35 @@ function buildConfig(values, positionals, flags) {
147
149
  flags: { ...flags, json: values.json === true },
148
150
  };
149
151
  }
152
+ function buildHar(values, positionals, flags) {
153
+ const [action, ...extra] = positionals;
154
+ if (!action || !HAR_ACTIONS.has(action) || extra.length > 0) {
155
+ throw new CliUsageError("Usage: videobook har list [--since <date>] [--failures] [--provider <p>] [--json] | har export --out <dir|file.tar.gz> [--since <date>] [--failures] [--provider <p>]");
156
+ }
157
+ const out = stringValue(values.out);
158
+ if (action === "export" && out === undefined)
159
+ throw new CliUsageError("har export needs --out <dir|file.tar.gz>");
160
+ if (action === "list" && out !== undefined)
161
+ throw new CliUsageError("--out applies to har export only");
162
+ const since = stringValue(values.since);
163
+ if (since !== undefined && !Number.isFinite(Date.parse(since)))
164
+ throw new CliUsageError(`--since needs an ISO date or time, got "${since}".`);
165
+ const providers = typeof values.provider === "string"
166
+ ? values.provider.split(",").map((provider) => provider.trim()).filter(Boolean)
167
+ : [];
168
+ return {
169
+ command: "har",
170
+ action: action,
171
+ flags: {
172
+ ...flags,
173
+ ...(out === undefined ? {} : { out }),
174
+ ...(since === undefined ? {} : { since }),
175
+ failures: values.failures === true,
176
+ providers,
177
+ json: values.json === true,
178
+ },
179
+ };
180
+ }
150
181
  function buildCommand(command, values, positionals) {
151
182
  const flags = globalFlags(values);
152
183
  switch (command) {
@@ -164,6 +195,8 @@ function buildCommand(command, values, positionals) {
164
195
  return buildSetup(values, positionals, flags);
165
196
  case "config":
166
197
  return buildConfig(values, positionals, flags);
198
+ case "har":
199
+ return buildHar(values, positionals, flags);
167
200
  default:
168
201
  return { command, flags };
169
202
  }
@@ -215,6 +248,8 @@ export function helpText() {
215
248
  " gc-books Reclaim catalog storage in every book",
216
249
  " doctor Check Node, media binaries, native modules and keys",
217
250
  " config [action] list | get <KEY> | set <KEY> <VALUE> | unset <KEY> in the data-home .env",
251
+ " har list List provider HAR captures, newest first",
252
+ " har export Copy scrub-checked captures and a manifest to share with apicity (--out)",
218
253
  " setup 1password Read keys from a 1Password vault (--op-vault, --op-token)",
219
254
  " version Print versions",
220
255
  " help Print this help",
@@ -228,7 +263,11 @@ export function helpText() {
228
263
  " --open serve: open the web UI in a browser",
229
264
  " --no-wait run: do not wait for queued jobs",
230
265
  " --timeout <s> run: seconds to wait for jobs (default 600)",
231
- " --json doctor, config: print machine-readable results",
266
+ " --json doctor, config, har: print machine-readable results",
267
+ " --out <path> har export: a new directory, or a .tar.gz/.tgz file",
268
+ " --since <date> har: only calls at or after this ISO date or time",
269
+ " --failures har: only failed calls",
270
+ " --provider <p> har: only these providers, comma separated",
232
271
  " --op-vault <name> setup 1password: vault whose items are named after the keys",
233
272
  " --op-token <tok> setup 1password: service-account token, best as env:VAR",
234
273
  " --no-verify setup 1password: skip the op item list check",
@@ -1,3 +1,4 @@
1
+ import { readHarSettings, writeHarSettings, harDirectory } from "../har-settings.js";
1
2
  import { hasControlCharacters, readEnvValues, updateEnvFile } from "../env-file.js";
2
3
  import { MANAGED_ENV_KEYS, getDefaultSecretStore, isSecretEnvKey, maskEnvValue, } from "../env-secrets.js";
3
4
  import { OP_TOKEN_SETTING, OP_VAULT_SETTING, isOpReference, vaultReference } from "../one-password.js";
@@ -83,10 +84,25 @@ async function unsetKey(key, envPath) {
83
84
  return 0;
84
85
  }
85
86
  export async function runConfigCommand(command) {
87
+ if (command.key === "har_capture") {
88
+ try {
89
+ const current = await readHarSettings();
90
+ const settings = command.action === "set" || command.action === "unset"
91
+ ? await writeHarSettings({ ...await readHarSettings(undefined, {}), mode: command.action === "unset" ? "off" : command.value })
92
+ : current;
93
+ process.stdout.write(`${JSON.stringify({ harCapture: settings, dir: harDirectory(), overridden: Boolean(process.env.VIDEOBOOK_HAR_CAPTURE?.trim()) })}\n`);
94
+ return 0;
95
+ }
96
+ catch (error) {
97
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
98
+ return 1;
99
+ }
100
+ }
86
101
  const envPath = getDefaultSecretStore().envPath();
87
102
  if (command.action === "list") {
88
103
  const file = readEnvValues(envPath);
89
- printRows(CONFIG_KEYS.map((key) => configRow(key, file)), file, command.flags.json, envPath);
104
+ const har = await readHarSettings();
105
+ printRows([...CONFIG_KEYS.map((key) => configRow(key, file)), { key: "har_capture", source: process.env.VIDEOBOOK_HAR_CAPTURE?.trim() ? "environment" : "app state", value: har.mode }], file, command.flags.json, envPath);
90
106
  return 0;
91
107
  }
92
108
  const key = command.key ?? "";
@@ -1,3 +1,4 @@
1
+ import { harDirectory, readHarSettings } from "../har-settings.js";
1
2
  import { execFile } from "node:child_process";
2
3
  import { access, constants, readdir } from "node:fs/promises";
3
4
  import { createRequire } from "node:module";
@@ -72,6 +73,19 @@ async function dataHomeCheck() {
72
73
  });
73
74
  }
74
75
  }
76
+ async function harCaptureCheck() {
77
+ const settings = await readHarSettings();
78
+ const dir = harDirectory();
79
+ let writable = true;
80
+ try {
81
+ await access(await nearestExisting(dir), constants.W_OK);
82
+ }
83
+ catch {
84
+ writable = false;
85
+ }
86
+ return check({ id: "har-capture", label: "HAR capture", status: writable || settings.mode === "off" ? "ok" : "warn",
87
+ detail: `${settings.mode}; ${dir}; writable: ${writable ? "yes" : "no"}`, required: false });
88
+ }
75
89
  async function libraryCheck() {
76
90
  const libraryDir = bookManager.rootDir;
77
91
  try {
@@ -255,6 +269,7 @@ async function collectDoctorChecks() {
255
269
  return [
256
270
  nodeCheck(),
257
271
  await dataHomeCheck(),
272
+ await harCaptureCheck(),
258
273
  await libraryCheck(),
259
274
  await binaryCheck("ffmpeg", "ffmpeg", FFMPEG, bundledPath("ffmpeg-static"), "VIDEOBOOK_FFMPEG_PATH"),
260
275
  await binaryCheck("ffprobe", "ffprobe", FFPROBE, bundledFfprobe(), "VIDEOBOOK_FFPROBE_PATH"),
@@ -0,0 +1,86 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import { exportHarCaptures, selectHarRows } from "../har-export.js";
3
+ import { readHarIndex } from "../har-index.js";
4
+ import { harDirectory } from "../har-settings.js";
5
+ const LISTED_SKIPS = 10;
6
+ function selection(flags) {
7
+ return {
8
+ since: flags.since === undefined ? null : Date.parse(flags.since),
9
+ failures: flags.failures,
10
+ providers: flags.providers,
11
+ };
12
+ }
13
+ async function listCaptures(flags) {
14
+ const dir = harDirectory();
15
+ const rows = selectHarRows(await readHarIndex(dir), selection(flags)).sort((a, b) => b.ts.localeCompare(a.ts));
16
+ if (flags.json) {
17
+ process.stdout.write(`${JSON.stringify({ dir, captures: rows }, null, 2)}\n`);
18
+ return 0;
19
+ }
20
+ const lines = rows.map((row) => [row.ts, row.provider, row.endpoint, row.outcome, row.status ?? "-", row.file].join("\t"));
21
+ process.stdout.write(`${["ts\tprovider\tendpoint\toutcome\tstatus\tfile", ...lines].join("\n")}\n`);
22
+ if (rows.length === 0)
23
+ process.stderr.write(`No matching captures in ${dir}\n`);
24
+ return 0;
25
+ }
26
+ function outcomeSummary(outcomes) {
27
+ return Object.entries(outcomes).filter(([, count]) => count > 0).map(([outcome, count]) => `${outcome} ${count}`).join(", ");
28
+ }
29
+ function shellPath(path) {
30
+ return /^[\w./:@%+=,-]+$/u.test(path) ? path : `'${path.replaceAll("'", "'\\''")}'`;
31
+ }
32
+ function handoff(out, archive, manifest) {
33
+ const target = shellPath(archive ? out.replace(/\.(?:tar\.gz|tgz)$/iu, "") : out);
34
+ return [
35
+ `Exported ${manifest.files} capture${manifest.files === 1 ? "" : "s"} (${manifest.dateRange.first} to ${manifest.dateRange.last}) to ${out}`,
36
+ ...manifest.providers.map((provider) => ` ${provider.provider}: ${provider.calls} (${outcomeSummary(provider.outcomes)})`),
37
+ `Every file passed the @apicity/har ${manifest.apicity.har} scrub check. The captures still contain prompts and media URLs; review them before sharing.`,
38
+ "",
39
+ "Hand-off to an apicity maintainer:",
40
+ ...(archive ? [` tar -xzf ${shellPath(out)} -C ${shellPath(dirname(out))}`] : []),
41
+ ` apicity har report ${target} > report.md`,
42
+ " apicity har scrub --check <capture.har>",
43
+ " pnpm run har:promote -- <capture.har> --name <provider>/<test-name> --dry-run (in the apicity repository, for a successful call)",
44
+ "",
45
+ ].join("\n");
46
+ }
47
+ function skippedWarning(skipped) {
48
+ if (skipped.length === 0)
49
+ return "";
50
+ const listed = skipped.slice(0, LISTED_SKIPS).map((file) => ` ${file}\n`).join("");
51
+ const more = skipped.length > LISTED_SKIPS ? ` and ${skipped.length - LISTED_SKIPS} more\n` : "";
52
+ return `Skipped ${skipped.length} indexed capture${skipped.length === 1 ? "" : "s"} that are missing or unreadable:\n${listed}${more}`;
53
+ }
54
+ async function exportCaptures(flags) {
55
+ const dir = harDirectory();
56
+ const out = resolve(process.cwd(), flags.out ?? "");
57
+ const result = await exportHarCaptures({ dir, out, ...selection(flags) });
58
+ switch (result.status) {
59
+ case "out-inside-captures":
60
+ process.stderr.write(`--out must be outside the capture directory ${dir}\n`);
61
+ return 2;
62
+ case "out-exists":
63
+ process.stderr.write(`${out} already exists; choose a new file or an empty directory\n`);
64
+ return 1;
65
+ case "needs-scrub":
66
+ process.stderr.write([
67
+ "Refusing to export: @apicity/har scrubHar would change these captures:",
68
+ ...result.files.map((file) => ` ${file}`),
69
+ "A scrub rule is missing upstream. Report it to apicity instead of editing the captures. Nothing was written.",
70
+ "",
71
+ ].join("\n"));
72
+ return 1;
73
+ case "empty":
74
+ process.stderr.write(`${skippedWarning(result.skipped)}No captures in ${dir} match the filters. Nothing was written.\n`);
75
+ return 1;
76
+ case "exported":
77
+ process.stderr.write(skippedWarning(result.skipped));
78
+ process.stdout.write(flags.json
79
+ ? `${JSON.stringify({ out: result.out, archive: result.archive, manifest: result.manifest, skipped: result.skipped }, null, 2)}\n`
80
+ : handoff(result.out, result.archive, result.manifest));
81
+ return 0;
82
+ }
83
+ }
84
+ export async function runHarCommand(command) {
85
+ return command.action === "export" ? exportCaptures(command.flags) : listCaptures(command.flags);
86
+ }
package/dist/cli/main.js CHANGED
@@ -54,6 +54,10 @@ async function runCommand(parsed) {
54
54
  const { runConfigCommand } = await import("./config-command.js");
55
55
  return runConfigCommand(parsed);
56
56
  }
57
+ case "har": {
58
+ const { runHarCommand } = await import("./har-command.js");
59
+ return runHarCommand(parsed);
60
+ }
57
61
  }
58
62
  }
59
63
  async function loadDotenv() {
@@ -77,7 +81,15 @@ async function main(argv) {
77
81
  redirectConsoleToStderr();
78
82
  if (parsed.command !== "setup" && parsed.command !== "config")
79
83
  await loadDotenv();
80
- return runCommand(parsed);
84
+ try {
85
+ return await runCommand(parsed);
86
+ }
87
+ finally {
88
+ if (parsed.command === "run" || parsed.command === "mcp") {
89
+ const { flushHarCaptures } = await import("../har-capture.js");
90
+ await flushHarCaptures();
91
+ }
92
+ }
81
93
  }
82
94
  function flushed(stream) {
83
95
  return new Promise((resolveFlush) => stream.write("", () => resolveFlush()));
@@ -90,7 +90,7 @@ export function renderGenerationModels() {
90
90
 
91
91
  Generated by \`npm run docs:commands\` from the media catalog (\`server/src/media/catalog.ts\`). Call \`generate_image\`, \`generate_video\` or \`generate_audio\` with one of these model ids; \`list_generation_models\` returns the same list with every option, enum, range and default read from the apicity schemas.
92
92
 
93
- Image and video pickers rank exact catalog endpoints using the committed top-10 snapshots in @apicity/cost 0.17.1, originally sourced from Artificial Analysis. No leaderboard is fetched at runtime. Rankings are per operation: t2i, i2i, t2v, i2v, v2v, and r2v (derived from the text/image-to-video boards). Each ranked option shows rank, Elo, and a bundled price label; prices are indicative ranges, not a quote for the selected settings.
93
+ Image and video pickers rank exact catalog endpoints using the committed top-10 snapshots in @apicity/cost 0.18.1, originally sourced from Artificial Analysis. No leaderboard is fetched at runtime. Rankings are per operation: t2i, i2i, t2v, i2v, v2v, and r2v (derived from the text/image-to-video boards). Each ranked option shows rank, Elo, and a bundled price label; prices are indicative ranges, not a quote for the selected settings.
94
94
 
95
95
  Configured frontier models appear first; other models retain their existing order in a separate group. Providers without credentials are marked unavailable. Saved selections and defaults stay unchanged. Catalog variants on a frontier board are included even if previously hidden; non-frontier hidden variants remain hidden. A model version or lite variant never inherits another endpoint's score.
96
96
 
@@ -98,6 +98,8 @@ Kling 4 supports text, frames, references and video editing; it appears in the u
98
98
 
99
99
  HeyGen Avatar 3 and 4 digital twins are text-to-video with a stock avatar (Abigail Office Front) and voice (Ivy); the prompt is the spoken script. Avatar 5 uses the SDK defaults Abigail Sofa Front and Warm Pro Narrator. Avatar 4 image-to-video animates an image with the script and defaults to Ivy. Connected audio overrides speech synthesis. Video Agent v2 and v3 accept a production prompt. HeyGen lipsync, translation, filler-word removal, HeyGen Voice TTS and Ideogram vector endpoints are outside this catalog addition.
100
100
 
101
+ Kie Kling 4.0 reference-to-video and keyframe-to-video, PixVerse V6 Max text-to-video and image-to-video, and OmniHuman 2.0 are on no top-10 board, so they appear in the unranked group. Kling 4.0 prompts name connected media as Kling 4.0 documents them: \`@image_1\` and \`@video_1\` for references, \`@first_frame\`, \`@keyframe_1\` and \`@last_frame\` for frames; with no media connected, both endpoints generate from the prompt alone. PixVerse V6 Max image-to-video follows the image's aspect ratio. OmniHuman 2.0 renders as long as its driving audio. The Kie Grok Imagine Video 1.5 preview takes first and last frames, a loop image (\`loop_url\`) and up to four timed \`keyframes\`, and shares the Grok Imagine Video 1.5 image-to-video ranking.
102
+
101
103
  Top-10 coverage policy: every registry endpoint must map exactly to a catalog entry or appear in the known gaps below. Models with no provider endpoint in the registry: ${unmapped.join(", ")}. Catalog gaps do not imply provider API support is missing; adding those endpoints is separate work.
102
104
 
103
105
  | Known endpoint gap | Reason |
@@ -1,5 +1,6 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
- import { B2Error, createB2 } from "@apicity/b2";
2
+ import { createB2 } from "./usage-providers.js";
3
+ import { B2Error } from "@apicity/b2";
3
4
  function createB2ContentStore(config) {
4
5
  const provider = createB2({
5
6
  accessKeyId: config.accessKeyId,
@@ -4,7 +4,7 @@ const EVENTS = [
4
4
  "book-open", "book-open-slow", "book-catalog-gc", "provider-poll-failed",
5
5
  "ace-server-exited", "scheduled-post-failed", "scheduled-post-rearm-failed",
6
6
  "generation-record-failed", "generation-update-failed", "generation-list-failed",
7
- "provider-task-record-failed",
7
+ "provider-task-record-failed", "har-capture-failed",
8
8
  "prompt-record-failed",
9
9
  "waveform-empty", "waveform-failed", "heartbeat-failed", "subtitle-cache-failed",
10
10
  "subtitle-translation-failed", "overlay-skipped", "kie-upload-fallback",
@@ -14,7 +14,7 @@ const EVENTS = [
14
14
  "process-video-enqueue-failed", "waveform-write-failed", "timeline-reencode",
15
15
  "pad-video-started", "video-analysis-failed", "description-truncated",
16
16
  "description-cache-failed", "watermark-placement-fallback", "waveform-enqueue-failed",
17
- "poster-enqueue-failed",
17
+ "poster-enqueue-failed", "proxy-assessment-enqueue-failed", "duration-backfill-enqueue-failed",
18
18
  "watermark-probe-fallback", "generation-error-clear-failed", "timeline-orientation-failed",
19
19
  "generation-cost-estimate", "job-error", "semantic-index-deferred",
20
20
  "tag-asset-failed", "voice-session-closed", "alive-captions-regions-failed",
@@ -48,6 +48,7 @@ export const MANAGED_ENV_KEYS = [
48
48
  "B2_ARCHIVE_PREFIX",
49
49
  ];
50
50
  const NON_SECRET_ENV_KEYS = new Set([
51
+ "VIDEOBOOK_HAR_CAPTURE",
51
52
  "B2_REGION",
52
53
  "B2_BUCKET",
53
54
  "B2_ENDPOINT",