mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -19,15 +19,45 @@ eof_line: "*** End of File" LF
19
19
  %import common.LF
20
20
  `;
21
21
 
22
+ // Public contract mirrors Codex: apply_patch is the PRIMARY edit tool and takes
23
+ // a raw freeform V4A patch (no JSON envelope) on providers that support custom
24
+ // grammar tools. No prior `read` is required or implied — send the patch as
25
+ // soon as the target and content are known. The JSON schema below is only the
26
+ // fallback for providers that cannot carry freeform/custom tools, so it exposes
27
+ // the patch string alone; runtime-only knobs stay off the model surface.
28
+ // Batching stays a rules-level policy: every new edit goes in one patch, with
29
+ // one file block per target.
22
30
  const APPLY_PATCH_FREEFORM_DESCRIPTION =
23
- 'Apply an atomic FREEFORM patch, not JSON. Use exact current context; roll back on failure.';
31
+ 'Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.';
32
+
33
+ // JSON-schema fallback providers (Anthropic and other non-grammar surfaces)
34
+ // get the full Codex V4A instructions inline: without a grammar the model has
35
+ // no format signal beyond this description, and the dominant one-shot failure
36
+ // modes (missing section headers, retyped context, marker resubmission) are
37
+ // exactly what these rules preempt. Mirrors
38
+ // refs/codex/codex-rs/prompts/templates/apply_patch_tool_instructions.md,
39
+ // adapted to the JSON `patch` argument.
40
+ const APPLY_PATCH_JSON_DESCRIPTION = [
41
+ 'Primary file-editing tool. Send `patch` as soon as the target and change are known.',
42
+ 'Use this V4A envelope:',
43
+ '*** Begin Patch',
44
+ '[file sections]',
45
+ '*** End Patch',
46
+ 'Every section starts with exactly one header:',
47
+ '- *** Add File: <path> then one or more +content lines.',
48
+ '- *** Delete File: <path> with nothing after it.',
49
+ '- *** Update File: <path>, optionally followed by *** Move to: <new path>.',
50
+ 'Updates contain hunks introduced by @@ or @@ <enclosing class/function>. Prefix every hunk line with space (context), - (remove), or + (add); a hunk at end of file may close with *** End of File.',
51
+ 'Copy 3 context lines above and below verbatim from the file. Do not duplicate overlapping context. If context is not unique, add one or more enclosing @@ headers.',
52
+ 'Use project-relative paths. Every added file line needs +. Never submit a compacted-history marker; re-read and create a fresh patch.',
53
+ ].join('\n');
24
54
 
25
55
  export const PATCH_TOOL_DEFS = [
26
56
  {
27
57
  name: 'apply_patch',
28
58
  title: 'Mixdog Apply Patch',
29
59
  annotations: { title: 'Mixdog Apply Patch', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, compressible: false, compressibleLossless: true },
30
- description: 'Apply one atomic patch; roll back all touched paths on failure.',
60
+ description: APPLY_PATCH_JSON_DESCRIPTION,
31
61
  freeformDescription: APPLY_PATCH_FREEFORM_DESCRIPTION,
32
62
  freeform: {
33
63
  type: 'grammar',
@@ -37,12 +67,7 @@ export const PATCH_TOOL_DEFS = [
37
67
  inputSchema: {
38
68
  type: 'object',
39
69
  properties: {
40
- patch: { type: 'string', description: 'V4A patch text; use exact current context; atomic rollback.' },
41
- format: { type: 'string', enum: ['unified', 'v4a'], description: 'Auto-detected.' },
42
- base_path: { type: 'string', description: 'Repo root.' },
43
- dry_run: { type: 'boolean', description: 'Default false. true = validate only, no write.' },
44
- fuzzy: { type: 'boolean', description: 'Default true. Allows limited context fuzz.' },
45
- reject_partial: { type: 'boolean', description: 'Default true. false allows V4A hunk-level rejects.' },
70
+ patch: { type: 'string', description: 'The V4A patch text to apply (format and context rules in the tool description).' },
46
71
  },
47
72
  required: ['patch'],
48
73
  },
@@ -214,6 +214,7 @@ export function execShellCommand({
214
214
  let failureReason = null;
215
215
  let spawnError = null;
216
216
  let pendingChildError = null;
217
+ let childGuardian = null;
217
218
  let settle = null;
218
219
  let settled = false;
219
220
  let timer = null;
@@ -398,10 +399,11 @@ export function execShellCommand({
398
399
  child = spawned.child;
399
400
  spawned.adoptErrorHandler(_onChildError);
400
401
  }
401
- startChildGuardian({
402
+ childGuardian = startChildGuardian({
402
403
  childPid: child.pid,
403
404
  childGroupPid: child.pid,
404
405
  label: 'shell-command',
406
+ protectHostMemory: true,
405
407
  });
406
408
  // Windows has no process-group handle. Begin observing while the owned
407
409
  // root still exists so descendants can be proven before root exit.
@@ -579,10 +581,18 @@ export function execShellCommand({
579
581
  // pool when clean, tree-kills it otherwise) and the lease frees after.
580
582
  const lease = resourceLease;
581
583
  resourceLease = null;
584
+ // The standby root survives this command and may return to the pool.
585
+ // Stop this command-scoped guardian so every reuse does not leave
586
+ // another detached Node process watching the same pwsh forever.
587
+ const standbyGuardian = childGuardian;
588
+ childGuardian = null;
582
589
  void _standby.recycle()
583
590
  .catch(() => false)
584
- .then(() => { try { return Promise.resolve(lease?.release()).catch(() => {}); } catch { return null; } });
591
+ .then(() => { try { return Promise.resolve(lease?.release()).catch(() => {}); } catch { return null; } })
592
+ .finally(() => standbyGuardian?.stop?.());
585
593
  } else {
594
+ childGuardian?.stop?.();
595
+ childGuardian = null;
586
596
  const leaseSettlement = resourceLeaseSettlement
587
597
  || releaseResourceLeaseWhenTreeQuiescent(child.pid);
588
598
  leaseSettlement.tracker?.rootExited?.();
@@ -7,7 +7,7 @@
7
7
  * already signed in to. No lane owns a fallback to another lane's credential —
8
8
  * an unauthenticated lane fails closed.
9
9
  */
10
- import { getAgentApiKey } from '../shared/provider-api-key.mjs';
10
+ import { hasAgentApiKey } from '../shared/provider-api-key.mjs';
11
11
  // Credential probes are file-level checks: the catalog must not drag the whole
12
12
  // provider graph in just to render lane availability.
13
13
  import {
@@ -155,7 +155,7 @@ const LANE_BY_ID = new Map(LANES.map((lane) => [lane.id, lane]));
155
155
 
156
156
  function laneAuthenticated(lane) {
157
157
  try {
158
- if (lane.auth.type === 'api-key') return Boolean(getAgentApiKey(lane.auth.provider));
158
+ if (lane.auth.type === 'api-key') return hasAgentApiKey(lane.auth.provider);
159
159
  if (lane.auth.provider === 'grok-oauth') return hasGrokOAuthCredentials();
160
160
  if (lane.auth.provider === 'openai-oauth') return hasOpenAIOAuthCredentials();
161
161
  } catch {
@@ -26,9 +26,15 @@ const SPECS = {
26
26
  };
27
27
  // Probe order when reading a cached rendition: sharp writes webp, the
28
28
  // ffmpeg-only poster path writes png.
29
- const EXTENSION_MIME = { '.webp': 'image/webp', '.png': 'image/png' };
29
+ const EXTENSION_MIME = {
30
+ '.webp': 'image/webp',
31
+ '.png': 'image/png',
32
+ '.jpg': 'image/jpeg',
33
+ };
30
34
  const POSTER_TIMEOUT_MS = 20_000;
31
35
  const MAX_POSTER_BYTES = 32 * 1024 * 1024;
36
+ const FAILED_RENDITION_COOLDOWN_MS = 30_000;
37
+ const MAX_CACHED_RENDITION_BYTES = 4 * 1024 * 1024;
32
38
 
33
39
  const MEDIA_VARIANTS = Object.keys(SPECS);
34
40
 
@@ -93,6 +99,40 @@ function writeRendition(cacheDir, variant, id, extension, buffer) {
93
99
  return { path, mime: EXTENSION_MIME[extension], bytes: buffer.length };
94
100
  }
95
101
 
102
+ export function createPriorityScheduler(limit = 2) {
103
+ const foreground = [];
104
+ const background = [];
105
+ let active = 0;
106
+ let backgroundActive = 0;
107
+ const concurrency = Math.max(1, limit);
108
+ const backgroundConcurrency = Math.max(1, concurrency - 1);
109
+ const pump = () => {
110
+ while (active < concurrency) {
111
+ const foregroundEntry = foreground.shift();
112
+ const entry = foregroundEntry
113
+ ?? (backgroundActive < backgroundConcurrency ? background.shift() : null);
114
+ if (!entry) return;
115
+ active += 1;
116
+ if (!foregroundEntry) backgroundActive += 1;
117
+ void Promise.resolve()
118
+ .then(entry.task)
119
+ .then(entry.resolve, entry.reject)
120
+ .finally(() => {
121
+ active -= 1;
122
+ if (!foregroundEntry) backgroundActive -= 1;
123
+ pump();
124
+ });
125
+ }
126
+ };
127
+ return (task, priority = 'foreground') => new Promise((resolve, reject) => {
128
+ const queue = priority === 'background' ? background : foreground;
129
+ queue.push({ task, resolve, reject });
130
+ pump();
131
+ });
132
+ }
133
+
134
+ const scheduleRendition = createPriorityScheduler(2);
135
+
96
136
  /** Downscale a still (path or buffer) into a webp; null without sharp. */
97
137
  async function encodeStill(input, spec) {
98
138
  const sharp = await loadSharp();
@@ -118,20 +158,33 @@ function parseDurationSeconds(text) {
118
158
  return Number.isFinite(seconds) ? Math.round(seconds) : 0;
119
159
  }
120
160
 
121
- /** First frame + duration of a clip, straight from ffmpeg; null when the
161
+ export function videoPosterArguments(sourcePath, spec) {
162
+ return [
163
+ '-hide_banner', '-nostdin',
164
+ '-i', sourcePath,
165
+ // The encoded first frame is frequently a black transition. An accurate
166
+ // post-input seek gives generated clips a representative early poster.
167
+ '-ss', '0.12',
168
+ '-frames:v', '1',
169
+ '-vf', `scale='min(${spec.maxEdge},iw)':-2`,
170
+ // Emit the tile JPEG directly. The old PNG -> sharp -> webp second encode
171
+ // doubled cold-video latency and failed when sharp was unavailable.
172
+ '-q:v', '4',
173
+ '-f', 'image2', '-vcodec', 'mjpeg', 'pipe:1',
174
+ ];
175
+ }
176
+
177
+ /** Representative frame + duration of a clip, straight from ffmpeg; null when the
122
178
  * managed runtime is not installed. */
123
179
  async function grabVideoFrame(sourcePath, spec) {
124
180
  const ffmpeg = await resolveFfmpeg();
125
181
  if (!ffmpeg) return null;
126
182
  return new Promise((resolve) => {
127
- const child = spawn(ffmpeg, [
128
- '-hide_banner', '-nostdin',
129
- '-i', sourcePath,
130
- '-frames:v', '1',
131
- // Never upscale a small clip: -2 keeps the height even for the encoder.
132
- '-vf', `scale='min(${spec.maxEdge},iw)':-2`,
133
- '-f', 'image2', '-vcodec', 'png', 'pipe:1',
134
- ], { stdio: ['ignore', 'pipe', 'pipe'] });
183
+ const child = spawn(
184
+ ffmpeg,
185
+ videoPosterArguments(sourcePath, spec),
186
+ { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true },
187
+ );
135
188
  const chunks = [];
136
189
  let total = 0;
137
190
  let stderr = '';
@@ -165,6 +218,7 @@ async function grabVideoFrame(sourcePath, spec) {
165
218
 
166
219
  // Two tiles asking for the same missing rendition must not both encode it.
167
220
  const inflight = new Map();
221
+ const failedUntil = new Map();
168
222
 
169
223
  async function generate({ id, kind, sourcePath, variant, spec, cacheDir }) {
170
224
  if (kind === 'video') {
@@ -173,10 +227,7 @@ async function generate({ id, kind, sourcePath, variant, spec, cacheDir }) {
173
227
  if (variant !== 'thumb') return null;
174
228
  const frame = await grabVideoFrame(sourcePath, spec);
175
229
  if (!frame) return null;
176
- const encoded = await encodeStill(frame.buffer, spec);
177
- const written = encoded
178
- ? writeRendition(cacheDir, variant, id, encoded.extension, encoded.buffer)
179
- : writeRendition(cacheDir, variant, id, '.png', frame.buffer);
230
+ const written = writeRendition(cacheDir, variant, id, '.jpg', frame.buffer);
180
231
  return { ...written, durationSeconds: frame.durationSeconds };
181
232
  }
182
233
  const encoded = await encodeStill(sourcePath, spec);
@@ -188,16 +239,49 @@ async function generate({ id, kind, sourcePath, variant, spec, cacheDir }) {
188
239
  * Returns `{ path, mime, bytes, durationSeconds? }`, or null when this host
189
240
  * cannot produce it (no sharp / no ffmpeg / undecodable source).
190
241
  */
191
- export async function ensureRendition({ id, kind, sourcePath, variant, cacheDir }) {
242
+ export async function ensureRendition({
243
+ id,
244
+ kind,
245
+ sourcePath,
246
+ variant,
247
+ cacheDir,
248
+ priority = 'foreground',
249
+ generate = true,
250
+ }) {
192
251
  const spec = renditionSpec(variant);
193
252
  if (!spec) return null;
194
253
  const cached = cachedRendition(cacheDir, variant, id);
195
254
  if (cached) return cached;
255
+ if (!generate) return null;
196
256
  const key = `${variant}:${id}`;
257
+ if ((failedUntil.get(key) || 0) > Date.now()) return null;
197
258
  const pending = inflight.get(key)
198
- ?? generate({ id, kind, sourcePath, variant, spec, cacheDir })
199
- .catch(() => null)
259
+ ?? scheduleRendition(
260
+ () => generate({ id, kind, sourcePath, variant, spec, cacheDir }),
261
+ priority,
262
+ )
263
+ .then((result) => {
264
+ if (result) failedUntil.delete(key);
265
+ else failedUntil.set(key, Date.now() + FAILED_RENDITION_COOLDOWN_MS);
266
+ return result;
267
+ })
268
+ .catch(() => {
269
+ failedUntil.set(key, Date.now() + FAILED_RENDITION_COOLDOWN_MS);
270
+ return null;
271
+ })
200
272
  .finally(() => { inflight.delete(key); });
201
273
  inflight.set(key, pending);
202
274
  return pending;
203
275
  }
276
+
277
+ /** Persist a small browser-generated fallback so a codec miss is paid once. */
278
+ export function cacheRendition({ id, variant = 'thumb', mime, buffer, cacheDir }) {
279
+ if (!renditionSpec(variant)) return null;
280
+ const extension = mime === 'image/webp'
281
+ ? '.webp'
282
+ : mime === 'image/png' ? '.png'
283
+ : mime === 'image/jpeg' ? '.jpg' : '';
284
+ if (!extension || !buffer?.length || buffer.length > MAX_CACHED_RENDITION_BYTES) return null;
285
+ failedUntil.delete(`${variant}:${id}`);
286
+ return writeRendition(cacheDir, variant, id, extension, buffer);
287
+ }
@@ -0,0 +1,54 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import {
5
+ createPriorityScheduler,
6
+ videoPosterArguments,
7
+ } from './renditions.mjs';
8
+
9
+ test('video posters seek off frame zero and encode one JPEG without a second still pass', () => {
10
+ const args = videoPosterArguments('clip.mp4', { maxEdge: 512 });
11
+ assert.deepEqual(args.slice(args.indexOf('-ss'), args.indexOf('-ss') + 2), ['-ss', '0.12']);
12
+ assert.equal(args[args.indexOf('-vcodec') + 1], 'mjpeg');
13
+ assert.equal(args.includes('png'), false);
14
+ });
15
+
16
+ test('rendition scheduler bounds work and prioritizes visible requests over queued warmups', async () => {
17
+ const schedule = createPriorityScheduler(2);
18
+ const releases = [];
19
+ const started = [];
20
+ let active = 0;
21
+ let peak = 0;
22
+ const blocked = (name) => schedule(() => new Promise((resolve) => {
23
+ started.push(name);
24
+ active += 1;
25
+ peak = Math.max(peak, active);
26
+ releases.push(() => {
27
+ active -= 1;
28
+ resolve(name);
29
+ });
30
+ }), 'background');
31
+ const first = blocked('background-a');
32
+ const second = blocked('background-b');
33
+ const third = blocked('background-c');
34
+ const foreground = schedule(async () => {
35
+ started.push('foreground');
36
+ active += 1;
37
+ peak = Math.max(peak, active);
38
+ active -= 1;
39
+ return 'foreground';
40
+ });
41
+ await new Promise((resolve) => setImmediate(resolve));
42
+ assert.deepEqual(started, ['background-a', 'foreground'],
43
+ 'one slot must stay available for a visible request');
44
+ assert.equal(await foreground, 'foreground');
45
+ releases.shift()();
46
+ await new Promise((resolve) => setImmediate(resolve));
47
+ assert.deepEqual(started, ['background-a', 'foreground', 'background-b']);
48
+ releases.shift()();
49
+ await new Promise((resolve) => setImmediate(resolve));
50
+ assert.deepEqual(started, ['background-a', 'foreground', 'background-b', 'background-c']);
51
+ releases.shift()();
52
+ await Promise.all([first, second, third]);
53
+ assert.equal(peak, 2);
54
+ });
@@ -12,11 +12,17 @@ import { randomUUID } from 'crypto';
12
12
  import { spawn } from 'child_process';
13
13
  import { resolvePluginData } from '../shared/plugin-paths.mjs';
14
14
  import { writeJsonAtomicSync, withFileLockSync } from '../shared/atomic-file.mjs';
15
- import { ensureRendition, removeRenditions, renditionSpec } from './renditions.mjs';
15
+ import {
16
+ cacheRendition,
17
+ ensureRendition,
18
+ removeRenditions,
19
+ renditionSpec,
20
+ } from './renditions.mjs';
16
21
 
17
22
  const MAX_INDEX_ENTRIES = 2_000;
18
23
  // Renderer transport is base64 over IPC: refuse to inline anything larger.
19
24
  const MAX_INLINE_BYTES = 48 * 1024 * 1024;
25
+ const MAX_CACHED_THUMBNAIL_BYTES = 4 * 1024 * 1024;
20
26
 
21
27
  const EXTENSIONS = {
22
28
  'image/png': 'png',
@@ -25,6 +31,12 @@ const EXTENSIONS = {
25
31
  'video/mp4': 'mp4',
26
32
  };
27
33
  let layoutMigrationChecked = false;
34
+ let indexCachePath = '';
35
+ let indexCacheSignature = '';
36
+ let indexCacheAssets = [];
37
+ let indexCacheById = new Map();
38
+ let indexDiskReads = 0;
39
+ let indexCacheHits = 0;
28
40
 
29
41
  function mediaDir() {
30
42
  return join(resolvePluginData(), 'media');
@@ -48,26 +60,60 @@ function indexLockPath() {
48
60
  return `${indexPath()}.lock`;
49
61
  }
50
62
 
51
- function ensureDirs() {
63
+ function ensureDirs({ migrate = false } = {}) {
52
64
  mkdirSync(assetsDir(), { recursive: true });
53
- if (!layoutMigrationChecked) {
65
+ if (migrate && !layoutMigrationChecked) {
54
66
  migrateAssetLayout();
55
67
  layoutMigrationChecked = true;
56
68
  }
57
69
  }
58
70
 
71
+ function indexSignature(path) {
72
+ try {
73
+ const info = statSync(path);
74
+ return `${info.mtimeMs}:${info.ctimeMs}:${info.size}`;
75
+ } catch {
76
+ return 'missing';
77
+ }
78
+ }
79
+
80
+ function rememberIndex(path, signature, assets) {
81
+ indexCachePath = path;
82
+ indexCacheSignature = signature;
83
+ indexCacheAssets = assets;
84
+ indexCacheById = new Map(assets.map((entry) => [entry.id, entry]));
85
+ return assets;
86
+ }
87
+
59
88
  function readIndex() {
89
+ const path = indexPath();
90
+ const signature = indexSignature(path);
91
+ if (path === indexCachePath && signature === indexCacheSignature) {
92
+ indexCacheHits += 1;
93
+ return indexCacheAssets;
94
+ }
60
95
  try {
61
- const raw = readFileSync(indexPath(), 'utf8');
96
+ const raw = readFileSync(path, 'utf8');
62
97
  const parsed = JSON.parse(raw);
63
- return Array.isArray(parsed?.assets) ? parsed.assets : [];
98
+ indexDiskReads += 1;
99
+ return rememberIndex(path, signature, Array.isArray(parsed?.assets) ? parsed.assets : []);
64
100
  } catch {
65
- return [];
101
+ return rememberIndex(path, signature, []);
66
102
  }
67
103
  }
68
104
 
69
105
  function writeIndex(assets) {
70
- writeJsonAtomicSync(indexPath(), { version: 2, assets });
106
+ const path = indexPath();
107
+ writeJsonAtomicSync(path, { version: 2, assets });
108
+ rememberIndex(path, indexSignature(path), assets);
109
+ }
110
+
111
+ export function mediaStoreCacheStats() {
112
+ return {
113
+ indexDiskReads,
114
+ indexCacheHits,
115
+ indexEntries: indexCacheAssets.length,
116
+ };
71
117
  }
72
118
 
73
119
  function extensionFor(mime) {
@@ -142,7 +188,9 @@ function migrateAssetLayout() {
142
188
 
143
189
  /** Persist one generated artifact and return its index entry. */
144
190
  export function saveMediaAsset({ kind, lane, model, prompt, options = {}, mime, bytes, meta = {} }) {
145
- ensureDirs();
191
+ // Moving legacy files is a write-side maintenance task. Running it from
192
+ // list/resolve made the first Studio read synchronously wait on index.lock.
193
+ ensureDirs({ migrate: true });
146
194
  const id = randomUUID();
147
195
  const createdAt = Date.now();
148
196
  const entry = {
@@ -180,6 +228,7 @@ export function saveMediaAsset({ kind, lane, model, prompt, options = {}, mime,
180
228
  sourcePath: path,
181
229
  variant: 'thumb',
182
230
  cacheDir: renditionsDir(),
231
+ priority: 'background',
183
232
  });
184
233
  return entry;
185
234
  }
@@ -190,41 +239,13 @@ export function listMediaAssets({ limit = 60, offset = 0, kind = null } = {}) {
190
239
  const start = Math.max(0, Math.trunc(Number(offset) || 0));
191
240
  const count = Math.min(200, Math.max(1, Math.trunc(Number(limit) || 60)));
192
241
  const assets = all.slice(start, start + count);
193
- // Assets generated before this cache existed have no tile rendition, so the
194
- // first gallery paint used to wait on an encode per visible tile. Listing is
195
- // the earliest moment the host knows WHICH tiles are coming: warm them here,
196
- // off the caller's path (ensureRendition dedupes the concurrent request).
197
- void prewarmThumbs(assets);
198
242
  return { total: all.length, assets };
199
243
  }
200
244
 
201
- // Bounded: the encoder is shared with generation, and a large page must not
202
- // turn one listing into dozens of parallel sharp/ffmpeg jobs.
203
- const PREWARM_LIMIT = 24;
204
- const PREWARM_CONCURRENCY = 2;
205
-
206
- async function prewarmThumbs(assets) {
207
- const queue = assets.slice(0, PREWARM_LIMIT);
208
- const workers = Array.from({ length: PREWARM_CONCURRENCY }, async () => {
209
- for (let entry = queue.shift(); entry; entry = queue.shift()) {
210
- const path = storedAssetPath(entry.file);
211
- if (!path || !existsSync(path)) continue;
212
- const rendition = await ensureRendition({
213
- id: entry.id,
214
- kind: entry.kind,
215
- sourcePath: path,
216
- variant: 'thumb',
217
- cacheDir: renditionsDir(),
218
- }).catch(() => null);
219
- if (rendition?.durationSeconds) rememberDuration(entry, rendition.durationSeconds);
220
- }
221
- });
222
- await Promise.all(workers).catch(() => {});
223
- }
224
-
225
245
  function getMediaAsset(id) {
226
246
  ensureDirs();
227
- return readIndex().find((entry) => entry.id === id) || null;
247
+ readIndex();
248
+ return indexCacheById.get(id) || null;
228
249
  }
229
250
 
230
251
  /** Persist a duration probed while building a poster, so the gallery badge
@@ -262,7 +283,10 @@ function inlineBase64(path) {
262
283
  * no ffmpeg); the caller reduces the feature instead of substituting the
263
284
  * original behind the client's back.
264
285
  */
265
- export async function resolveMediaFile(id, { variant = 'original' } = {}) {
286
+ export async function resolveMediaFile(id, {
287
+ variant = 'original',
288
+ generate = true,
289
+ } = {}) {
266
290
  const entry = getMediaAsset(id);
267
291
  if (!entry) return null;
268
292
  const path = storedAssetPath(entry.file);
@@ -275,6 +299,7 @@ export async function resolveMediaFile(id, { variant = 'original' } = {}) {
275
299
  sourcePath: path,
276
300
  variant,
277
301
  cacheDir: renditionsDir(),
302
+ generate,
278
303
  });
279
304
  if (!rendition) return { ...entry, variant, path: '', available: false };
280
305
  rememberDuration(entry, rendition.durationSeconds);
@@ -297,8 +322,12 @@ export async function resolveMediaFile(id, { variant = 'original' } = {}) {
297
322
  * so (`available: false`) instead of quietly shipping full-size bytes — the
298
323
  * caller that can afford them (local IPC) opts in with `allowOriginal`.
299
324
  */
300
- export async function readMediaAsset(id, { variant = 'original', allowOriginal = false } = {}) {
301
- const file = await resolveMediaFile(id, { variant });
325
+ export async function readMediaAsset(id, {
326
+ variant = 'original',
327
+ allowOriginal = false,
328
+ generate = true,
329
+ } = {}) {
330
+ const file = await resolveMediaFile(id, { variant, generate });
302
331
  if (!file) return null;
303
332
  if (file.available) return { ...file, base64: inlineBase64(file.path) };
304
333
  if (!allowOriginal) return { ...file, base64: '' };
@@ -307,6 +336,39 @@ export async function readMediaAsset(id, { variant = 'original', allowOriginal =
307
336
  return { ...original, downgraded: true, base64: inlineBase64(original.path) };
308
337
  }
309
338
 
339
+ /** Cache a bounded thumbnail produced by Chromium when native codecs miss. */
340
+ export function cacheMediaThumbnail(id, input = {}) {
341
+ const entry = getMediaAsset(id);
342
+ if (!entry) return null;
343
+ const base64 = typeof input.base64 === 'string' ? input.base64 : '';
344
+ if (!base64 || base64.length > Math.ceil(MAX_CACHED_THUMBNAIL_BYTES * 4 / 3) + 8) {
345
+ return { id, available: false };
346
+ }
347
+ const buffer = Buffer.from(base64, 'base64');
348
+ if (!buffer.length || buffer.length > MAX_CACHED_THUMBNAIL_BYTES) {
349
+ return { id, available: false };
350
+ }
351
+ const mime = String(input.mime || 'image/jpeg').toLowerCase();
352
+ const rendition = cacheRendition({
353
+ id: entry.id,
354
+ variant: 'thumb',
355
+ mime,
356
+ buffer,
357
+ cacheDir: renditionsDir(),
358
+ });
359
+ if (!rendition) return { id, available: false };
360
+ const durationSeconds = Math.max(0, Math.round(Number(input.durationSeconds) || 0));
361
+ if (durationSeconds) rememberDuration(entry, durationSeconds);
362
+ return {
363
+ id,
364
+ available: true,
365
+ variant: 'thumb',
366
+ mime: rendition.mime,
367
+ bytes: rendition.bytes,
368
+ ...(durationSeconds ? { durationSeconds } : {}),
369
+ };
370
+ }
371
+
310
372
  export function mediaAssetPath(id) {
311
373
  const entry = getMediaAsset(id);
312
374
  return entry ? storedAssetPath(entry.file) : null;
@@ -322,8 +384,14 @@ export function openMediaAsset(id) {
322
384
  return { id, opened: openWithOs(path) };
323
385
  }
324
386
 
325
- /** Open the assets directory itself (Studio → folder button). */
326
- export function openMediaFolder() {
387
+ /** Open one asset's containing directory, or the Studio assets root. */
388
+ export function openMediaFolder(id = '') {
389
+ if (id) {
390
+ const path = mediaAssetPath(id);
391
+ if (!path || !existsSync(path)) return { id, path: '', opened: false };
392
+ const folder = dirname(path);
393
+ return { id, path: folder, opened: openWithOs(folder) };
394
+ }
327
395
  ensureDirs();
328
396
  return { path: assetsDir(), opened: openWithOs(assetsDir()) };
329
397
  }