@lalalic/markcut 3.2.5 → 3.2.7

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/.env.example CHANGED
@@ -25,3 +25,13 @@ GOOGLE_MAPS_API_KEY=your_key_here
25
25
  # MARKCUT_MAX_VIDEO_DURATION=60
26
26
  # MARKCUT_MAX_VIDEO_DIMENSION=360
27
27
  # MARKCUT_VTT_SAMPLE_INTERVAL=5
28
+ # ── Default Vision backend policy ────────────────────────────────────────────
29
+ # Browser ChatGPT Temporary Chat is attempted first when this command exists.
30
+ # MARKCUT_CHATGPT_BROWSER_INFER_CLI=/path/to/chatgpt-browser-infer
31
+ # Codex fallback profiles are tried left-to-right; `default` means no -p flag.
32
+ # MARKCUT_CODEX_VISION_PROFILES=zai,default
33
+ # MARKCUT_CHATGPT_VISION_TIMEOUT_MS=600000
34
+ # MARKCUT_CODEX_VISION_TIMEOUT_MS=600000
35
+ # Local MLX is final fallback only.
36
+ # MARKCUT_LOCAL_VISION_MODEL=mlx-community/MiniCPM-V-4.6-bf16
37
+ # MARKCUT_LOCAL_VISION_TIMEOUT_MS=600000
package/README.md CHANGED
@@ -341,4 +341,32 @@ flowchart LR
341
341
  | `--dry-run` | Show what would be processed without running AI |
342
342
  | `--show-prompts` | Print the prompts template file and exit |
343
343
 
344
+ ### Default Vision backend priority
345
+
346
+ Markcut Vision is remote-first by default so ordinary perception does not require a heavy local VLM on the host Mac. Explicit `--itt` / `--vtt` flags and `MARKCUT_ITT_CLI` / `MARKCUT_VTT_CLI` environment variables still override these defaults.
347
+
348
+ Default ITT tries, in order:
349
+
350
+ 1. **Browser ChatGPT Temporary Chat** through `chatgpt-browser-infer` (or `MARKCUT_CHATGPT_BROWSER_INFER_CLI`). The Neo runtime owns browser automation, isolation, attachment readiness, retries, and cleanup.
351
+ 2. **Codex CLI remote vision**, trying profiles from `MARKCUT_CODEX_VISION_PROFILES` (default `zai,default`). On this Mac that means the lightweight Z.ai profile first, then the normal Codex/OpenAI profile.
352
+ 3. **Local MLX MiniCPM** as the final fallback only. Override its model with `MARKCUT_LOCAL_VISION_MODEL`.
353
+
354
+ Default VTT is intentionally empty. Markcut therefore performs its existing deterministic chronological frame extraction (5–10 representative frames, controlled by `--vtt-sample-interval`) and sends those frames through the same ITT priority chain. Set `--vtt` or `MARKCUT_VTT_CLI` when a dedicated video backend is explicitly desired.
355
+
356
+ To expose the Neo Browser ChatGPT runtime on this Mac:
357
+
358
+ ```bash
359
+ ln -sf "$HOME/Workspace/neo/skills/chatgpt-browser-worker/bin/chatgpt-browser-infer" "$HOME/.local/bin/chatgpt-browser-infer"
360
+ ```
361
+
362
+ Useful policy overrides:
363
+
364
+ ```bash
365
+ export MARKCUT_CODEX_VISION_PROFILES='zai,default'
366
+ export MARKCUT_CHATGPT_BROWSER_INFER_CLI='/path/to/chatgpt-browser-infer'
367
+ # Explicit custom backends still win:
368
+ export MARKCUT_ITT_CLI='my-image-backend --prompt "{prompt}" {input}'
369
+ export MARKCUT_VTT_CLI='my-video-backend --prompt "{prompt}" {input}'
370
+ ```
371
+
344
372
  ## Architecture
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lalalic/markcut",
3
- "version": "3.2.5",
3
+ "version": "3.2.7",
4
4
  "description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
5
5
  "bin": {
6
6
  "markcut": "bin/markcut"
@@ -22,6 +22,7 @@
22
22
  "render:demo": "node src/render/cli.mjs render src/demo-components.json",
23
23
  "templates": "node src/render/cli.mjs templates",
24
24
  "preview": "node src/render/cli.mjs preview",
25
+ "mcp": "node src/mcp/server.mjs",
25
26
  "verify": "node src/render/cli.mjs verify",
26
27
  "resolve": "node src/render/cli.mjs resolve",
27
28
  "render:pixelle": "remotion render examples/pixelle/index.tsx Root out/pixelle-asset-default.mp4 --props=./examples/pixelle/asset-default.json",
@@ -37,6 +38,7 @@
37
38
  },
38
39
  "dependencies": {
39
40
  "@babel/standalone": "^8.0.3",
41
+ "@modelcontextprotocol/sdk": "^1.32.1",
40
42
  "@remotion/cli": "4.0.469",
41
43
  "@remotion/media-parser": "4.0.469",
42
44
  "@remotion/media-utils": "4.0.469",
package/src/config.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { fileURLToPath } from "node:url";
2
+
1
3
  /**
2
4
  * Default CLI templates and constants for the markcut pipeline.
3
5
  *
@@ -96,7 +98,8 @@ export const DEFAULT_EDIT_CLI = args.cliOverrides.editCli || process.env.MARKCUT
96
98
  export const DEFAULT_TTI_CLI = args.cliOverrides.tti || process.env.MARKCUT_TTI_CLI || 'uvx --from mflux mflux-generate-flux2 --model flux2-klein-4b --steps 2 --prompt "{input}" --output "{output}" --seed {seed}';
97
99
  /** Text-to-video CLI. Override via --ttv flag or MARKCUT_TTV_CLI env var. */
98
100
  export const DEFAULT_TTV_CLI = args.cliOverrides.ttv || process.env.MARKCUT_TTV_CLI || '';
101
+ const DEFAULT_VISION_INFER = fileURLToPath(new URL("./vision/default-infer.mjs", import.meta.url));
99
102
  /** Image-to-text CLI. Override via --itt flag or MARKCUT_ITT_CLI env var. */
100
- export const DEFAULT_ITT_CLI = args.cliOverrides.itt || process.env.MARKCUT_ITT_CLI || 'uvx --from mlx-vlm mlx_vlm.generate --model mlx-community/MiniCPM-V-4.6-bf16 --max-tokens 2048 --prompt "{prompt}" --image {input} --temperature 0.0 --thinking-mode disabled';;
101
- /** Video-to-text CLI. Override via --vtt flag or MARKCUT_VTT_CLI env var. */
102
- export const DEFAULT_VTT_CLI = args.cliOverrides.vtt || process.env.MARKCUT_VTT_CLI || 'uvx --from mlx-vlm mlx_vlm.generate --model mlx-community/MiniCPM-V-4.6-bf16 --max-tokens 2048 --prompt "{prompt}" --video {input} --temperature 0.0 --processor-kwargs \'{"max_num_frames": 32, "stack_frames": 1, "max_slice_nums": 1, "use_image_id": false}\'';
103
+ export const DEFAULT_ITT_CLI = args.cliOverrides.itt || process.env.MARKCUT_ITT_CLI || `node ${JSON.stringify(DEFAULT_VISION_INFER)} --prompt "{prompt}" {input}`;
104
+ /** Video-to-text CLI. Override via --vtt flag or MARKCUT_VTT_CLI env var. Empty by default so Vision uses chronological frame extraction through ITT. */
105
+ export const DEFAULT_VTT_CLI = args.cliOverrides.vtt || process.env.MARKCUT_VTT_CLI || '';
@@ -0,0 +1,15 @@
1
+ export function buildPreviewBridgeScript(submitToolName) {
2
+ return `
3
+ const feedback=document.getElementById('feedback'),status=document.getElementById('status'),article=document.querySelector('article'),pathLabel=document.querySelector('main>p code');
4
+ let nextRequestId=0;
5
+ const pendingRequests=new Map();
6
+ function send(message){window.parent.postMessage(message,'*');}
7
+ function rpcRequest(method,params){const id=++nextRequestId;send({jsonrpc:'2.0',id,method,params});return new Promise((resolve,reject)=>pendingRequests.set(id,{resolve,reject}));}
8
+ function applyToolResult(params){const result=params?.result||params||{},data=result.structuredContent||result.structured_content||{};if(typeof data.path==='string')pathLabel.textContent=data.path;if(typeof data.markdown==='string'){article.innerHTML=data.markdown.split(/\\r?\\n/).map(line=>{const escaped=line.replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));if(line.startsWith('### '))return '<h3>'+escaped.slice(4)+'</h3>';if(line.startsWith('## '))return '<h2>'+escaped.slice(3)+'</h2>';if(line.startsWith('# '))return '<h1>'+escaped.slice(2)+'</h1>';if(line.startsWith('- '))return '<li>'+escaped.slice(2)+'</li>';return line.trim()?'<p>'+escaped+'</p>':''}).join('\\n');}}
9
+ window.addEventListener('message',event=>{if(event.source!==window.parent)return;const message=event.data;if(!message||typeof message!=='object'||message.jsonrpc!=='2.0')return;if(Object.prototype.hasOwnProperty.call(message,'id')&&pendingRequests.has(message.id)){const pending=pendingRequests.get(message.id);pendingRequests.delete(message.id);if(message.error)pending.reject(new Error(message.error.message||'MCP request failed'));else pending.resolve(message.result);return;}if(message.method==='ui/notifications/tool-result')applyToolResult(message.params);});
10
+ async function initializeBridge(){try{await rpcRequest('ui/initialize',{protocolVersion:'2026-01-26',capabilities:{},clientInfo:{name:'markcut-preview',version:'1'}});send({jsonrpc:'2.0',method:'ui/notifications/initialized'});status.textContent='Ready for review.';}catch(error){status.textContent=error.message||'MCP initialization failed.';}}
11
+ async function submit(decision){const value=feedback.value.trim();if(decision==='changes_requested'&&!value){status.textContent='Feedback is required.';feedback.focus();return;}const result={decision};if(value&&decision==='changes_requested')result.feedback=value;status.textContent='Submitting...';try{await rpcRequest('tools/call',{name:'${submitToolName}',arguments:result});send({jsonrpc:'2.0',method:'ui/message',params:{role:'user',content:[{type:'text',text:JSON.stringify(result)}]}});status.textContent=decision==='approved'?'Approved.':'Changes requested.';}catch(error){status.textContent=error.message||'Unable to submit review.';}}
12
+ initializeBridge();
13
+ document.getElementById('approve').onclick=()=>submit('approved');document.getElementById('changes').onclick=()=>submit('changes_requested');
14
+ `;
15
+ }
@@ -0,0 +1,77 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { isAbsolute, extname } from "node:path";
3
+ import { buildPreviewBridgeScript } from "./bridge.mjs";
4
+
5
+ export const TOOL_NAME = "markcut.preview";
6
+ export const SUBMIT_TOOL_NAME = "markcut.preview.submit";
7
+ export const RESOURCE_URI = "ui://markcut/markdown-preview.html";
8
+
9
+ export function validateMarkdownPath(value) {
10
+ if (typeof value !== "string" || !isAbsolute(value)) {
11
+ throw new Error("path must be an absolute filesystem path");
12
+ }
13
+ if (extname(value).toLowerCase() !== ".md") {
14
+ throw new Error("path must point to a Markdown file (.md)");
15
+ }
16
+ return value;
17
+ }
18
+
19
+ export async function readMarkdownPreview(path) {
20
+ const validatedPath = validateMarkdownPath(path);
21
+ try {
22
+ return { path: validatedPath, markdown: await readFile(validatedPath, "utf8") };
23
+ } catch (error) {
24
+ throw new Error(`unable to read Markdown file: ${error instanceof Error ? error.message : String(error)}`);
25
+ }
26
+ }
27
+
28
+ export function reviewResult(decision, feedback = "") {
29
+ if (decision === "approved") return { decision };
30
+ if (decision === "changes_requested") {
31
+ const normalized = typeof feedback === "string" ? feedback.trim() : "";
32
+ if (!normalized) throw new Error("feedback is required when requesting changes");
33
+ return { decision, feedback: normalized };
34
+ }
35
+ throw new Error(`unsupported review decision: ${String(decision)}`);
36
+ }
37
+
38
+ export function reviewMessage(result) {
39
+ const normalized = reviewResult(result?.decision, result?.feedback);
40
+ return {
41
+ jsonrpc: "2.0",
42
+ method: "ui/message",
43
+ params: {
44
+ role: "user",
45
+ content: [{ type: "text", text: JSON.stringify(normalized) }],
46
+ },
47
+ };
48
+ }
49
+
50
+ function escapeHtml(value) {
51
+ return value.replace(/[&<>"']/g, (character) => ({
52
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
53
+ })[character]);
54
+ }
55
+
56
+ function renderMarkdown(markdown) {
57
+ return markdown.split(/\r?\n/).map((line) => {
58
+ const escaped = escapeHtml(line);
59
+ if (line.startsWith("### ")) return `<h3>${escaped.slice(4)}</h3>`;
60
+ if (line.startsWith("## ")) return `<h2>${escaped.slice(3)}</h2>`;
61
+ if (line.startsWith("# ")) return `<h1>${escaped.slice(2)}</h1>`;
62
+ if (line.startsWith("- ")) return `<li>${escaped.slice(2)}</li>`;
63
+ return line.trim() ? `<p>${escaped}</p>` : "";
64
+ }).join("\n");
65
+ }
66
+
67
+ export function buildPreviewHtml({ path = "", markdown = "" }) {
68
+ const safePath = escapeHtml(path);
69
+ const rendered = renderMarkdown(markdown);
70
+ return `<!doctype html>
71
+ <html><head><meta charset="utf-8"><title>MarkCut Markdown review</title>
72
+ <style>body{font:15px system-ui,sans-serif;margin:0;padding:24px;color:#17202a;background:#fff}main{max-width:860px;margin:auto}article{border:1px solid #d8dee4;border-radius:8px;padding:20px;overflow:auto}h1,h2,h3{margin-top:0}p{line-height:1.5}textarea{box-sizing:border-box;width:100%;min-height:100px;margin:16px 0 10px;padding:10px;border:1px solid #8c959f;border-radius:6px;font:inherit}button{border:0;border-radius:6px;padding:9px 14px;margin-right:8px;font:inherit;cursor:pointer}#approve{background:#1f883d;color:#fff}#changes{background:#cf222e;color:#fff}#status{min-height:1.4em;color:#cf222e}</style></head>
73
+ <body><main><h1>Review Markdown</h1><p><code>${safePath}</code></p><article>${rendered}</article>
74
+ <label for="feedback">Feedback (required for requested changes)</label><textarea id="feedback" placeholder="What should be changed?"></textarea><div id="status" role="status"></div>
75
+ <button id="approve">Approve</button><button id="changes">Request changes</button></main>
76
+ <script>${buildPreviewBridgeScript(SUBMIT_TOOL_NAME)}</script></body></html>`;
77
+ }
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import {
5
+ CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema,
6
+ ReadResourceRequestSchema,
7
+ } from "@modelcontextprotocol/sdk/types.js";
8
+ import {
9
+ TOOL_NAME, SUBMIT_TOOL_NAME, RESOURCE_URI, buildPreviewHtml, readMarkdownPreview, reviewResult,
10
+ } from "./preview.mjs";
11
+
12
+ const server = new Server(
13
+ { name: "markcut", version: "3.2.0" },
14
+ { capabilities: { tools: {}, resources: {} } },
15
+ );
16
+
17
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
18
+ tools: [{
19
+ name: TOOL_NAME,
20
+ description: "Ask a human to review a Markdown file and return an approval or requested changes.",
21
+ inputSchema: {
22
+ type: "object", properties: { path: { type: "string", description: "Absolute path to a .md file" } },
23
+ required: ["path"], additionalProperties: false,
24
+ },
25
+ _meta: { "ui/resourceUri": RESOURCE_URI, ui: { resourceUri: RESOURCE_URI } },
26
+ }, {
27
+ name: SUBMIT_TOOL_NAME,
28
+ description: "Submit the review decision from the Markdown preview UI.",
29
+ inputSchema: {
30
+ type: "object",
31
+ properties: {
32
+ decision: { type: "string", enum: ["approved", "changes_requested"] },
33
+ feedback: { type: "string" },
34
+ },
35
+ required: ["decision"], additionalProperties: false,
36
+ },
37
+ _meta: { ui: { visibility: ["app"] } },
38
+ }],
39
+ }));
40
+
41
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
42
+ resources: [{ uri: RESOURCE_URI, name: "MarkCut Markdown review", mimeType: "text/html;profile=mcp-app" }],
43
+ }));
44
+
45
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => ({
46
+ contents: [{ uri: RESOURCE_URI, mimeType: "text/html;profile=mcp-app", text: buildPreviewHtml({ path: "", markdown: "" }) }],
47
+ }));
48
+
49
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
50
+ try {
51
+ const input = request.params.arguments ?? {};
52
+ if (request.params.name === SUBMIT_TOOL_NAME) {
53
+ const decision = reviewResult(input.decision, input.feedback);
54
+ return { content: [{ type: "text", text: JSON.stringify(decision) }], structuredContent: decision };
55
+ }
56
+ if (request.params.name !== TOOL_NAME) throw new Error(`unknown tool: ${request.params.name}`);
57
+ const { path, markdown } = await readMarkdownPreview(input.path);
58
+ return {
59
+ content: [{ type: "text", text: `Markdown ready for review: ${path}` }],
60
+ structuredContent: { path, markdown, resourceUri: RESOURCE_URI },
61
+ _meta: {
62
+ "ui/resourceUri": RESOURCE_URI,
63
+ ui: { resourceUri: RESOURCE_URI, props: { path, markdown } },
64
+ },
65
+ };
66
+ } catch (error) {
67
+ return { isError: true, content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }] };
68
+ }
69
+ });
70
+
71
+ await server.connect(new StdioServerTransport());
@@ -395,7 +395,8 @@ function extractVideoFrames(videoPath, intervalSeconds = DEFAULT_VTT_SAMPLE_INTE
395
395
  catch (e) { try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} throw new Error(`Frame extraction failed: ${e.message}`); }
396
396
  const frames = readdirSync(tmpDir).filter((f) => f.startsWith(baseName) && f.endsWith(".jpg")).sort().map((f) => join(tmpDir, f));
397
397
  if (frames.length === 0) { try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} throw new Error("No frames extracted from video"); }
398
- return { frames, cleanup: () => { try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} } };
398
+ const timing = frames.map((frame, i) => ({ frame, seconds: ((i + 0.5) * Math.max(duration || 5, 1)) / frames.length }));
399
+ return { frames, timing, cleanup: () => { try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} } };
399
400
  }
400
401
 
401
402
  function runVTT(videoPath, promptText, vttCli, ittCli, sampleInterval = DEFAULT_VTT_SAMPLE_INTERVAL) {
@@ -416,7 +417,11 @@ function runVTT(videoPath, promptText, vttCli, ittCli, sampleInterval = DEFAULT_
416
417
  let extracted;
417
418
  try { extracted = extractVideoFrames(videoPath, sampleInterval); }
418
419
  catch (e) { emitWarn(` ${e.message}`); return ""; }
419
- const result = runITT(extracted.frames, promptText, ittCli);
420
+ const timingContext = extracted.timing
421
+ .map((item, i) => `Frame ${i + 1}: approximately ${item.seconds.toFixed(1)}s`)
422
+ .join("\n");
423
+ const framePrompt = `${promptText}\n\nThe attached frames are ordered chronologically. Approximate timing:\n${timingContext}`;
424
+ const result = runITT(extracted.frames, framePrompt, ittCli);
420
425
  extracted.cleanup();
421
426
  return result;
422
427
  }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { accessSync, constants, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const LOCAL_MODEL = process.env.MARKCUT_LOCAL_VISION_MODEL || "mlx-community/MiniCPM-V-4.6-bf16";
8
+
9
+ function parseArgs(argv) {
10
+ let prompt = "";
11
+ const inputs = [];
12
+ for (let i = 0; i < argv.length; i++) {
13
+ const arg = argv[i];
14
+ if (arg === "--prompt") prompt = argv[++i] || "";
15
+ else if (arg === "--input") inputs.push(argv[++i]);
16
+ else if (arg && !arg.startsWith("--")) inputs.push(arg.startsWith("@") ? arg.slice(1) : arg);
17
+ }
18
+ return { prompt, inputs: inputs.filter(Boolean) };
19
+ }
20
+
21
+ function which(name) {
22
+ if (!name) return "";
23
+ const candidates = name.includes("/") ? [name] : (process.env.PATH || "").split(":").filter(Boolean).map(dir => join(dir, name));
24
+ for (const candidate of candidates) {
25
+ try { accessSync(candidate, constants.X_OK); return candidate; } catch {}
26
+ }
27
+ return "";
28
+ }
29
+
30
+ function run(command, args, options = {}) {
31
+ const r = spawnSync(command, args, { encoding: "utf8", timeout: options.timeout || 600_000, env: process.env });
32
+ return { ok: r.status === 0, stdout: (r.stdout || "").trim(), stderr: (r.stderr || "").trim(), status: r.status };
33
+ }
34
+
35
+ function tryBrowser(prompt, inputs) {
36
+ const explicit = process.env.MARKCUT_CHATGPT_BROWSER_INFER_CLI || "";
37
+ const command = explicit || which("chatgpt-browser-infer");
38
+ if (!command) return { ok: false, reason: "browser-infer-unavailable" };
39
+ const args = ["--prompt", prompt];
40
+ for (const input of inputs) args.push("--file", input);
41
+ const r = run(command, args, { timeout: Number(process.env.MARKCUT_CHATGPT_VISION_TIMEOUT_MS) || 600_000 });
42
+ return r.ok && r.stdout ? { ok: true, output: r.stdout } : { ok: false, reason: r.stderr || `browser-infer-exit-${r.status}` };
43
+ }
44
+
45
+ function tryCodex(prompt, inputs) {
46
+ const codex = which("codex");
47
+ if (!codex) return { ok: false, reason: "codex-unavailable" };
48
+ const profiles = (process.env.MARKCUT_CODEX_VISION_PROFILES || "zai,default").split(",").map(s => s.trim()).filter(Boolean);
49
+ const errors = [];
50
+ for (const profile of profiles) {
51
+ const dir = mkdtempSync(join(tmpdir(), "markcut-codex-vision-"));
52
+ const output = join(dir, "result.txt");
53
+ const args = ["exec", "--ephemeral", "--skip-git-repo-check"];
54
+ if (profile !== "default") args.push("-p", profile);
55
+ if (inputs.length) args.push("-i", ...inputs);
56
+ args.push("-o", output, prompt);
57
+ const r = run(codex, args, { timeout: Number(process.env.MARKCUT_CODEX_VISION_TIMEOUT_MS) || 600_000 });
58
+ let text = "";
59
+ if (existsSync(output)) text = readFileSync(output, "utf8").trim();
60
+ rmSync(dir, { recursive: true, force: true });
61
+ if (r.ok && text) return { ok: true, output: text };
62
+ errors.push(`${profile}:${r.stderr || `exit-${r.status}`}`);
63
+ }
64
+ return { ok: false, reason: errors.join("; ") || "codex-failed" };
65
+ }
66
+
67
+ function tryLocal(prompt, inputs) {
68
+ const uvx = which("uvx");
69
+ if (!uvx) return { ok: false, reason: "uvx-unavailable" };
70
+ const args = ["--from", "mlx-vlm", "mlx_vlm.generate", "--model", LOCAL_MODEL, "--max-tokens", "2048", "--prompt", prompt, "--image", ...inputs, "--temperature", "0.0", "--thinking-mode", "disabled"];
71
+ const r = run(uvx, args, { timeout: Number(process.env.MARKCUT_LOCAL_VISION_TIMEOUT_MS) || 600_000 });
72
+ return r.ok && r.stdout ? { ok: true, output: r.stdout } : { ok: false, reason: r.stderr || `local-vlm-exit-${r.status}` };
73
+ }
74
+
75
+ export function infer({ prompt, inputs }) {
76
+ if (!prompt) throw new Error("--prompt is required");
77
+ if (!inputs?.length) throw new Error("at least one image input is required");
78
+
79
+ const attempts = [
80
+ ["browser-chatgpt", () => tryBrowser(prompt, inputs)],
81
+ ["codex", () => tryCodex(prompt, inputs)],
82
+ ["local-vlm", () => tryLocal(prompt, inputs)],
83
+ ];
84
+ const failures = [];
85
+ for (const [name, fn] of attempts) {
86
+ const result = fn();
87
+ if (result.ok) return result.output;
88
+ failures.push(`${name}: ${result.reason}`);
89
+ }
90
+ throw new Error(`all vision backends failed (${failures.join(" | ")})`);
91
+ }
92
+
93
+ if (import.meta.url === `file://${process.argv[1]}`) {
94
+ try {
95
+ const { prompt, inputs } = parseArgs(process.argv.slice(2));
96
+ process.stdout.write(infer({ prompt, inputs }) + "\n");
97
+ } catch (error) {
98
+ process.stderr.write(`${error?.message || error}\n`);
99
+ process.exit(1);
100
+ }
101
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import vm from "node:vm";
6
+ import { buildPreviewHtml, readMarkdownPreview, reviewMessage, reviewResult, validateMarkdownPath } from "../src/mcp/preview.mjs";
7
+ import { buildPreviewBridgeScript } from "../src/mcp/bridge.mjs";
8
+
9
+ describe("MarkCut MCP Markdown preview", () => {
10
+ it("requires an absolute .md path", () => {
11
+ expect(() => validateMarkdownPath("notes.md")).toThrow(/absolute/);
12
+ expect(() => validateMarkdownPath("/tmp/notes.txt")).toThrow(/Markdown/);
13
+ });
14
+
15
+ it("reads Markdown and builds a reviewable UI resource", async () => {
16
+ const directory = mkdtempSync(join(tmpdir(), "markcut-mcp-"));
17
+ const path = join(directory, "review.md");
18
+ writeFileSync(path, "# Hello\n\nThis is a review.");
19
+ const preview = await readMarkdownPreview(path);
20
+ const html = buildPreviewHtml(preview);
21
+ expect(preview.markdown).toContain("# Hello");
22
+ expect(html).toContain("This is a review.");
23
+ expect(html).toContain("Approve");
24
+ expect(html).toContain("Request changes");
25
+ expect(html).toContain("ui/initialize");
26
+ expect(html).toContain("ui/notifications/tool-result");
27
+ expect(html).toContain("tools/call");
28
+ expect(html).toContain("ui/message");
29
+ expect(html).toContain("review.md");
30
+ });
31
+
32
+ it("returns approved", () => {
33
+ expect(reviewResult("approved")).toEqual({ decision: "approved" });
34
+ });
35
+
36
+ it("requires non-empty feedback for requested changes", () => {
37
+ expect(() => reviewResult("changes_requested", " ")).toThrow(/feedback is required/);
38
+ expect(reviewResult("changes_requested", " Fix the title ")).toEqual({
39
+ decision: "changes_requested", feedback: "Fix the title",
40
+ });
41
+ });
42
+
43
+ it("formats decisions as an MCP Apps ui/message notification", () => {
44
+ expect(reviewMessage({ decision: "approved" })).toEqual({
45
+ jsonrpc: "2.0",
46
+ method: "ui/message",
47
+ params: { role: "user", content: [{ type: "text", text: '{"decision":"approved"}' }] },
48
+ });
49
+ expect(reviewMessage({ decision: "changes_requested", feedback: "Fix the title" }).params.content[0].text)
50
+ .toBe('{"decision":"changes_requested","feedback":"Fix the title"}');
51
+ });
52
+
53
+ it("performs the MCP Apps handshake and awaits a successful submit", async () => {
54
+ const harness = createBridgeHarness();
55
+ harness.run();
56
+ expect(harness.messages[0]).toMatchObject({ id: 1, method: "ui/initialize" });
57
+ harness.respond({ id: 1, result: { protocolVersion: "2026-01-26" } });
58
+ await tick();
59
+ expect(harness.messages[1]).toMatchObject({ method: "ui/notifications/initialized" });
60
+
61
+ harness.elements.approve.onclick();
62
+ expect(harness.messages[2]).toMatchObject({ id: 2, method: "tools/call" });
63
+ expect(harness.messages).not.toContainEqual(expect.objectContaining({ method: "ui/message" }));
64
+ harness.respond({ id: 2, result: { structuredContent: { decision: "approved" } } });
65
+ await tick();
66
+ expect(harness.messages[3]).toMatchObject({ method: "ui/message" });
67
+ expect(new Set(harness.messages.filter((message) => "id" in message).map((message) => message.id)).size).toBe(2);
68
+ });
69
+
70
+ it("surfaces submit errors without sending a decision message and rejects invalid bridge messages", async () => {
71
+ const harness = createBridgeHarness();
72
+ harness.run();
73
+ harness.dispatch({ source: {}, data: { jsonrpc: "2.0", id: 1, result: {} } });
74
+ expect(harness.elements.status.textContent).toBe("");
75
+ harness.respond({ id: 1, result: {} });
76
+ await tick();
77
+ harness.elements.changes.onclick();
78
+ expect(harness.elements.status.textContent).toBe("Feedback is required.");
79
+ harness.elements.feedback.value = "Fix the title";
80
+ harness.elements.changes.onclick();
81
+ harness.respond({ id: 2, error: { message: "submit failed" } });
82
+ await tick();
83
+ expect(harness.elements.status.textContent).toBe("submit failed");
84
+ expect(harness.messages.some((message) => message.method === "ui/message")).toBe(false);
85
+ });
86
+ });
87
+
88
+ function tick() {
89
+ return new Promise((resolve) => setTimeout(resolve, 0));
90
+ }
91
+
92
+ function createBridgeHarness() {
93
+ const messages: any[] = [];
94
+ const listeners: ((event: any) => void)[] = [];
95
+ const element = (value = "") => ({ value, textContent: "", innerHTML: "", onclick: undefined as (() => void) | undefined, focus() {} });
96
+ const elements = {
97
+ feedback: element(),
98
+ status: element(),
99
+ article: element(),
100
+ pathLabel: element(),
101
+ approve: element(),
102
+ changes: element(),
103
+ };
104
+ const parent = { postMessage(message: any) { messages.push(message); } };
105
+ const document = {
106
+ getElementById(id: string) { return id === "feedback" ? elements.feedback : id === "status" ? elements.status : id === "approve" ? elements.approve : elements.changes; },
107
+ querySelector(selector: string) { return selector === "article" ? elements.article : elements.pathLabel; },
108
+ };
109
+ const window = { parent, addEventListener(_type: string, listener: (event: any) => void) { listeners.push(listener); } };
110
+ const context = vm.createContext({ window, document });
111
+ return {
112
+ messages,
113
+ elements,
114
+ run() { vm.runInContext(buildPreviewBridgeScript("markcut.preview.submit"), context); },
115
+ dispatch(event: any) { listeners.forEach((listener) => listener(event)); },
116
+ respond(message: any) { this.dispatch({ source: parent, data: { jsonrpc: "2.0", ...message } }); },
117
+ };
118
+ }
@@ -0,0 +1,83 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawnSync } from "node:child_process";
7
+
8
+ const ROOT = resolve(fileURLToPath(import.meta.url), "..", "..");
9
+ const HELPER = join(ROOT, "src", "vision", "default-infer.mjs");
10
+ const tempDirs: string[] = [];
11
+
12
+ function tempBin() {
13
+ const dir = mkdtempSync(join(tmpdir(), "markcut-vision-test-"));
14
+ tempDirs.push(dir);
15
+ return dir;
16
+ }
17
+
18
+ function script(dir: string, name: string, body: string) {
19
+ const path = join(dir, name);
20
+ writeFileSync(path, `#!/bin/sh\n${body}\n`);
21
+ chmodSync(path, 0o755);
22
+ return path;
23
+ }
24
+
25
+ function runHelper(bin: string) {
26
+ return spawnSync(process.execPath, [HELPER, "--prompt", "describe", "/tmp/a.jpg"], {
27
+ encoding: "utf8",
28
+ env: {
29
+ ...process.env,
30
+ PATH: `${bin}:/bin:/usr/bin`,
31
+ MARKCUT_CHATGPT_BROWSER_INFER_CLI: "",
32
+ MARKCUT_CODEX_VISION_PROFILES: "zai,default",
33
+ },
34
+ });
35
+ }
36
+
37
+ afterEach(() => {
38
+ for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
39
+ });
40
+
41
+ describe("default vision inference priority", () => {
42
+ it("uses Browser ChatGPT first", () => {
43
+ const bin = tempBin();
44
+ script(bin, "chatgpt-browser-infer", 'echo browser-result');
45
+ script(bin, "codex", 'echo codex-should-not-run >&2; exit 9');
46
+ script(bin, "uvx", 'echo local-should-not-run >&2; exit 9');
47
+ const r = runHelper(bin);
48
+ expect(r.status).toBe(0);
49
+ expect(r.stdout.trim()).toBe("browser-result");
50
+ });
51
+
52
+ it("falls back to Codex after Browser ChatGPT failure", () => {
53
+ const bin = tempBin();
54
+ script(bin, "chatgpt-browser-infer", 'exit 2');
55
+ script(bin, "codex", 'out=""; while [ "$#" -gt 0 ]; do if [ "$1" = "-o" ]; then out="$2"; shift 2; else shift; fi; done; echo codex-result > "$out"');
56
+ script(bin, "uvx", 'echo local-should-not-run >&2; exit 9');
57
+ const r = runHelper(bin);
58
+ expect(r.status).toBe(0);
59
+ expect(r.stdout.trim()).toBe("codex-result");
60
+ });
61
+
62
+ it("uses local VLM only after Browser ChatGPT and Codex fail", () => {
63
+ const bin = tempBin();
64
+ script(bin, "chatgpt-browser-infer", 'exit 2');
65
+ script(bin, "codex", 'exit 3');
66
+ script(bin, "uvx", 'echo local-result');
67
+ const r = runHelper(bin);
68
+ expect(r.status).toBe(0);
69
+ expect(r.stdout.trim()).toBe("local-result");
70
+ });
71
+
72
+ it("config defaults ITT to the policy helper and VTT to frame-based ITT", () => {
73
+ const code = 'import("./src/config.mjs").then(m => console.log(JSON.stringify({itt:m.DEFAULT_ITT_CLI,vtt:m.DEFAULT_VTT_CLI})))';
74
+ const env = { ...process.env } as Record<string, string>;
75
+ delete env.MARKCUT_ITT_CLI;
76
+ delete env.MARKCUT_VTT_CLI;
77
+ const r = spawnSync(process.execPath, ["--input-type=module", "-e", code], { cwd: ROOT, encoding: "utf8", env });
78
+ expect(r.status).toBe(0);
79
+ const value = JSON.parse(r.stdout.trim());
80
+ expect(value.itt).toContain("default-infer.mjs");
81
+ expect(value.vtt).toBe("");
82
+ });
83
+ });