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.
- package/package.json +13 -7
- package/scripts/tool-failures.mjs +60 -24
- package/src/agents/heavy-worker/AGENT.md +3 -4
- package/src/agents/worker/AGENT.md +1 -2
- package/src/cli.mjs +8 -0
- package/src/defaults/cycle3-review-prompt.md +4 -4
- package/src/rules/agent/00-core.md +3 -0
- package/src/rules/agent/42-cycle3-agent.md +5 -6
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/01-tool.md +29 -35
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
- package/src/runtime/agent/orchestrator/config.mjs +29 -11
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
- package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
- package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
- package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
- package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
- package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
- package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
- package/src/runtime/media/lanes.mjs +2 -2
- package/src/runtime/media/renditions.mjs +101 -17
- package/src/runtime/media/renditions.test.mjs +54 -0
- package/src/runtime/media/store.mjs +111 -43
- package/src/runtime/media/store.test.mjs +53 -11
- package/src/runtime/memory/index.mjs +26 -50
- package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
- package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
- package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
- package/src/runtime/memory/lib/http-router.mjs +4 -0
- package/src/runtime/memory/lib/ko-morph.mjs +49 -6
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
- package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
- package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
- package/src/runtime/memory/lib/query-handlers.mjs +26 -28
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/atomic-file.mjs +5 -1
- package/src/runtime/shared/child-guardian.mjs +73 -2
- package/src/runtime/shared/provider-api-key.mjs +22 -5
- package/src/runtime/shared/tool-execution-contract.mjs +53 -0
- package/src/session-runtime/config-helpers.mjs +6 -5
- package/src/session-runtime/lifecycle-api.mjs +16 -4
- package/src/session-runtime/media-api.mjs +31 -16
- package/src/session-runtime/prewarm.mjs +2 -24
- package/src/session-runtime/runtime-core.mjs +33 -15
- package/src/session-runtime/runtime-tunables.mjs +0 -3
- package/src/session-runtime/session-lifecycle.mjs +0 -6
- package/src/session-runtime/session-title.mjs +228 -0
- package/src/session-runtime/session-turn-api.mjs +11 -7
- package/src/session-runtime/workflow-agents-api.mjs +16 -0
- package/src/session-runtime/workflow.mjs +7 -5
- package/src/standalone/agent-tool/job-views.mjs +90 -36
- package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
- package/src/standalone/agent-tool/worker-index.mjs +8 -0
- package/src/standalone/agent-tool/worker-rows.mjs +2 -1
- package/src/standalone/agent-tool.mjs +17 -17
- package/src/standalone/agent-watchdog-registry.mjs +19 -2
- package/src/standalone/channel-daemon.mjs +8 -0
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/standalone/memory-runtime-proxy.mjs +4 -7
- package/src/standalone/projects.mjs +4 -1
- package/src/standalone/usage-dashboard.mjs +25 -3
- package/src/tui/App.jsx +5 -3
- package/src/tui/app/resume-picker.mjs +7 -2
- package/src/tui/app/route-pickers.mjs +11 -117
- package/src/tui/app/slash-dispatch.mjs +13 -3
- package/src/tui/app/transcript-row-estimate.mjs +1 -1
- package/src/tui/app/transcript-window.mjs +17 -93
- package/src/tui/app/use-transcript-scroll.mjs +32 -2
- package/src/tui/app/use-transcript-window.mjs +64 -114
- package/src/tui/components/Markdown.jsx +19 -2
- package/src/tui/components/Message.jsx +8 -3
- package/src/tui/components/ToolExecution.jsx +7 -3
- package/src/tui/dist/index.mjs +243 -223
- package/src/tui/engine/live-share.mjs +15 -5
- package/src/tui/engine/render-timing.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +74 -4
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine.mjs +2 -2
- package/src/tui/hooks/useSharedTick.mjs +0 -2
- package/src/tui/index.jsx +5 -5
- package/src/ui/statusline-agents.mjs +44 -11
- package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
- package/src/workflows/default/WORKFLOW.md +3 -3
- package/src/workflows/solo/WORKFLOW.md +5 -3
- package/src/runtime/media/index.mjs +0 -18
- package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
- package/src/standalone/agent-shard/shard-child.mjs +0 -300
- 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
|
-
'
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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({
|
|
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
|
-
??
|
|
199
|
-
|
|
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 {
|
|
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(
|
|
96
|
+
const raw = readFileSync(path, 'utf8');
|
|
62
97
|
const parsed = JSON.parse(raw);
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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, {
|
|
301
|
-
|
|
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
|
|
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
|
}
|