@ronana/mcp 0.3.2 → 0.4.0
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/README.md +15 -0
- package/dist/cli.js +13 -0
- package/dist/listen.d.ts +56 -0
- package/dist/listen.js +186 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,6 +39,7 @@ ronana generate get <id> | wait <id> [--out dir]
|
|
|
39
39
|
ronana generate list
|
|
40
40
|
ronana upscale <id | file | url> --resolution 4k --out ./renders
|
|
41
41
|
ronana folder list | add <folder> <id…>
|
|
42
|
+
ronana listen [--with claude|codex|gemini|ollama|command] [--model m]
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
Every field from `model get` is a flag (`--aspect_ratio 16:9`, `--seed 7`).
|
|
@@ -53,6 +54,14 @@ the full list.
|
|
|
53
54
|
the same command run twice makes two generations. Pass `--idempotency-key` to
|
|
54
55
|
make a retry bill once.
|
|
55
56
|
|
|
57
|
+
`listen` lets the Ronana studio send work to an AI on your computer. While it
|
|
58
|
+
runs, the studio's assistant and agent list it under their model menu, and
|
|
59
|
+
Enhance, assistant messages and agent turns go to it instead of Ronana's own
|
|
60
|
+
models, so they cost no credits. Renders still do, and agent cards wait for
|
|
61
|
+
your Confirm. `--with` picks the AI (Claude Code by default); `--with command
|
|
62
|
+
--command "…"` runs any program that reads the prompt on stdin and prints its
|
|
63
|
+
answer.
|
|
64
|
+
|
|
56
65
|
## Use Ronana from an AI app
|
|
57
66
|
|
|
58
67
|
Most AI apps connect to Ronana's remote MCP server directly. Add this URL as a
|
|
@@ -112,6 +121,7 @@ library.
|
|
|
112
121
|
| --- | --- |
|
|
113
122
|
| `list_models` | List the available models. |
|
|
114
123
|
| `get_model` | Get one model's contract: its input schema. |
|
|
124
|
+
| `get_prompt_guide` | Get the guide for writing a prompt for one model. |
|
|
115
125
|
| `estimate_cost` | Estimate the credit cost of a generation. Takes the same `model` and `input` as `generate`. |
|
|
116
126
|
| `generate` | Start an image or video generation. |
|
|
117
127
|
| `get_generation` | Get one generation and its download link. Pass `wait_seconds` (up to 45) to wait for the result in one call. |
|
|
@@ -124,6 +134,11 @@ library.
|
|
|
124
134
|
| `list_folders` | List the folders in the library. |
|
|
125
135
|
| `add_to_folder` | File library items into a folder. |
|
|
126
136
|
| `get_account` | Get the plan and credit balance. |
|
|
137
|
+
| `wait_for_requests` | Wait for Enhance, assistant and agent work from the studio. While an app calls it, the studio lists it as listening. |
|
|
138
|
+
| `get_request` | Read one request the studio sent, by its id. |
|
|
139
|
+
| `request_progress` | Tell the studio you are still working on a request you took, so a slow answer is not treated as abandoned. |
|
|
140
|
+
| `agent_tool` | Look up models, prompt guides and prices for an agent request. |
|
|
141
|
+
| `reply` | Answer a request: a prompt for the composer, a message, or cards for the board. |
|
|
127
142
|
|
|
128
143
|
A generate call takes a `model` and an `input`, exactly as `get_model` shows it
|
|
129
144
|
in `input_schema`. A media field takes a public https URL or a Ronana id
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// ronana generate list [--limit n]
|
|
15
15
|
// ronana upscale <id | file | https-url> [--resolution r] [--model m] [--wait] [--out dir]
|
|
16
16
|
// ronana folder list | add <folder> <id…>
|
|
17
|
+
// ronana listen [--with claude|codex|gemini|ollama|command] [--model m] [--command "…"]
|
|
17
18
|
import { randomUUID } from "node:crypto";
|
|
18
19
|
import { createWriteStream, existsSync, statSync } from "node:fs";
|
|
19
20
|
import { mkdir } from "node:fs/promises";
|
|
@@ -22,6 +23,7 @@ import { Readable } from "node:stream";
|
|
|
22
23
|
import { pipeline } from "node:stream/promises";
|
|
23
24
|
import { login, logout, readCredentials } from "./oauth.js";
|
|
24
25
|
import { callTool, remoteClient, ToolError, uploadFile } from "./remote.js";
|
|
26
|
+
import { listen, runnerFor } from "./listen.js";
|
|
25
27
|
import { buildInput, flag, has, isRemoteValue, parseArgs, replaceLocalMedia, typeOf } from "./input.js";
|
|
26
28
|
const HELP = `Usage: ronana <command>
|
|
27
29
|
|
|
@@ -40,6 +42,9 @@ const HELP = `Usage: ronana <command>
|
|
|
40
42
|
upscale <id|file|url> Upscale a video (costs credits; --resolution, --model)
|
|
41
43
|
folder list Your library folders
|
|
42
44
|
folder add <folder> <id…> File items into a folder (made if missing)
|
|
45
|
+
listen Let the studio send Enhance and agent work to an AI
|
|
46
|
+
on this computer (--with claude|codex|gemini|ollama|command,
|
|
47
|
+
--model m, --command "…", --app "name"). No credits.
|
|
43
48
|
|
|
44
49
|
Generate flags:
|
|
45
50
|
--<field> <value> Any field from \`model get\`. Repeat for lists.
|
|
@@ -347,6 +352,14 @@ async function main() {
|
|
|
347
352
|
case "upscale": return upscaleCommand(args);
|
|
348
353
|
case "folder":
|
|
349
354
|
case "folders": return folderCommand(command === "folders" ? "list" : sub, args);
|
|
355
|
+
case "listen": {
|
|
356
|
+
const runner = runnerFor(flag(args, "with") ?? "claude", { model: flag(args, "model"), command: flag(args, "command"), app: flag(args, "app") });
|
|
357
|
+
const remote = await remoteClient();
|
|
358
|
+
const stop = () => { void remote.close().catch(() => null).finally(() => process.exit(0)); };
|
|
359
|
+
process.once("SIGINT", stop);
|
|
360
|
+
process.once("SIGTERM", stop);
|
|
361
|
+
return listen(remote, runner, (line) => process.stderr.write(`${line}\n`));
|
|
362
|
+
}
|
|
350
363
|
default: throw new Error(`Unknown command "${command}". Run \`ronana --help\`.`);
|
|
351
364
|
}
|
|
352
365
|
}
|
package/dist/listen.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type remoteClient } from "./remote.js";
|
|
2
|
+
type Remote = Awaited<ReturnType<typeof remoteClient>>;
|
|
3
|
+
export interface Runner {
|
|
4
|
+
app: string;
|
|
5
|
+
command: string;
|
|
6
|
+
args: string[];
|
|
7
|
+
shell: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** The AIs `--with` knows. Each reads the prompt on stdin and prints its answer. */
|
|
10
|
+
export declare function runnerFor(name: string, options: {
|
|
11
|
+
model?: string;
|
|
12
|
+
command?: string;
|
|
13
|
+
app?: string;
|
|
14
|
+
}): Runner;
|
|
15
|
+
/** Run the AI once. Resolves with what it printed. */
|
|
16
|
+
export declare function runOnce(runner: Runner, prompt: string, timeoutMs: number): Promise<string>;
|
|
17
|
+
interface EnhanceRequest {
|
|
18
|
+
id: string;
|
|
19
|
+
kind: "enhance";
|
|
20
|
+
model: {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
};
|
|
24
|
+
draft: string;
|
|
25
|
+
brief?: string;
|
|
26
|
+
guide: string;
|
|
27
|
+
max_chars: number;
|
|
28
|
+
}
|
|
29
|
+
interface AgentRequest {
|
|
30
|
+
id: string;
|
|
31
|
+
kind: "agent";
|
|
32
|
+
messages: {
|
|
33
|
+
role: string;
|
|
34
|
+
content: string;
|
|
35
|
+
}[];
|
|
36
|
+
references: {
|
|
37
|
+
target: unknown;
|
|
38
|
+
kind: string;
|
|
39
|
+
label: string;
|
|
40
|
+
duration_s?: number;
|
|
41
|
+
}[];
|
|
42
|
+
context?: string;
|
|
43
|
+
approval_mode: string;
|
|
44
|
+
max_cards: number;
|
|
45
|
+
card_schema: unknown;
|
|
46
|
+
}
|
|
47
|
+
export type StudioRequest = EnhanceRequest | AgentRequest;
|
|
48
|
+
/** The prompt between <prompt> tags, or the whole answer when it has none. */
|
|
49
|
+
export declare function extractPrompt(text: string): string;
|
|
50
|
+
/** The first JSON object in the answer, fenced or bare. */
|
|
51
|
+
export declare function extractJson(text: string): Record<string, unknown> | null;
|
|
52
|
+
export declare function enhancePrompt(request: EnhanceRequest, refusal?: string): string;
|
|
53
|
+
export declare function agentPrompt(request: AgentRequest, catalog: string, refusal?: string): string;
|
|
54
|
+
/** Listen until Ctrl+C. */
|
|
55
|
+
export declare function listen(remote: Remote, runner: Runner, log: (line: string) => void): Promise<never>;
|
|
56
|
+
export {};
|
package/dist/listen.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// `ronana listen`: keep an AI on this computer listening for work from the
|
|
2
|
+
// Ronana studio. While it runs, the studio lists it under its model menus and
|
|
3
|
+
// sends it Enhance and agent requests instead of using Ronana's own models, so
|
|
4
|
+
// the thinking runs on the user's own AI and costs no Ronana credits.
|
|
5
|
+
//
|
|
6
|
+
// The loop holds `wait_for_requests` open, runs the chosen AI once per request
|
|
7
|
+
// with the prompt on stdin, and sends its answer with `reply`. The server
|
|
8
|
+
// checks every reply; a refused one is handed back to the AI once to fix.
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { callTool, ToolError } from "./remote.js";
|
|
11
|
+
/** The AIs `--with` knows. Each reads the prompt on stdin and prints its answer. */
|
|
12
|
+
export function runnerFor(name, options) {
|
|
13
|
+
const shell = process.platform === "win32";
|
|
14
|
+
const app = options.app?.trim();
|
|
15
|
+
switch (name) {
|
|
16
|
+
case "claude": return { app: app || "Claude Code", command: "claude", args: ["-p", "--output-format", "text", ...(options.model ? ["--model", options.model] : [])], shell };
|
|
17
|
+
case "codex": return { app: app || "Codex", command: "codex", args: ["exec", ...(options.model ? ["-m", options.model] : []), "-"], shell };
|
|
18
|
+
case "gemini": return { app: app || "Gemini CLI", command: "gemini", args: options.model ? ["-m", options.model] : [], shell };
|
|
19
|
+
case "ollama":
|
|
20
|
+
if (!options.model)
|
|
21
|
+
throw new Error("--with ollama needs --model <name>, e.g. --model llama3.1.");
|
|
22
|
+
return { app: app || `Ollama ${options.model}`, command: "ollama", args: ["run", options.model], shell };
|
|
23
|
+
case "command":
|
|
24
|
+
if (!options.command?.trim())
|
|
25
|
+
throw new Error("--with command needs --command \"<your command>\"; it gets the prompt on stdin.");
|
|
26
|
+
return { app: app || "Desktop AI", command: options.command, args: [], shell: true };
|
|
27
|
+
default: throw new Error(`Unknown --with "${name}". Use claude, codex, gemini, ollama or command.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Run the AI once. Resolves with what it printed. */
|
|
31
|
+
export function runOnce(runner, prompt, timeoutMs) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const child = spawn(runner.command, runner.args, { shell: runner.shell, stdio: ["pipe", "pipe", "pipe"] });
|
|
34
|
+
let out = "";
|
|
35
|
+
let err = "";
|
|
36
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error(`${runner.app} took longer than ${Math.round(timeoutMs / 1000)}s.`)); }, timeoutMs);
|
|
37
|
+
child.stdout.on("data", (chunk) => { out += chunk.toString("utf8"); });
|
|
38
|
+
child.stderr.on("data", (chunk) => { err += chunk.toString("utf8"); });
|
|
39
|
+
child.on("error", (error) => { clearTimeout(timer); reject(new Error(`Could not start ${runner.command}: ${error.message}`)); });
|
|
40
|
+
child.on("close", (code) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
if (code === 0)
|
|
43
|
+
resolve(out);
|
|
44
|
+
else
|
|
45
|
+
reject(new Error(`${runner.app} exited with code ${code}${err.trim() ? `: ${err.trim().split("\n").slice(-3).join(" ")}` : ""}`));
|
|
46
|
+
});
|
|
47
|
+
child.stdin.end(prompt);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** The prompt between <prompt> tags, or the whole answer when it has none. */
|
|
51
|
+
export function extractPrompt(text) {
|
|
52
|
+
const tagged = /<prompt>([\s\S]*?)(?:<\/prompt>|$)/i.exec(text);
|
|
53
|
+
return (tagged ? tagged[1] : text).trim();
|
|
54
|
+
}
|
|
55
|
+
/** The first JSON object in the answer, fenced or bare. */
|
|
56
|
+
export function extractJson(text) {
|
|
57
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text);
|
|
58
|
+
const candidates = [fenced?.[1], text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1)];
|
|
59
|
+
for (const raw of candidates) {
|
|
60
|
+
if (!raw?.trim())
|
|
61
|
+
continue;
|
|
62
|
+
try {
|
|
63
|
+
const value = JSON.parse(raw);
|
|
64
|
+
if (value && typeof value === "object" && !Array.isArray(value))
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
catch { /* try the next shape */ }
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
export function enhancePrompt(request, refusal) {
|
|
72
|
+
return [
|
|
73
|
+
`Rewrite this draft into the best prompt for ${request.model.name}, following the guide.`,
|
|
74
|
+
"Keep every reference token exactly as the draft writes it (@image1, @video1, @audio1).",
|
|
75
|
+
`Answer with the final prompt only, between <prompt> and </prompt>, at most ${request.max_chars} characters.`,
|
|
76
|
+
"", "GUIDE:", request.guide,
|
|
77
|
+
...(request.brief ? ["", "BRIEF:", request.brief] : []),
|
|
78
|
+
"", "DRAFT:", request.draft,
|
|
79
|
+
...(refusal ? ["", `Your last answer was refused: ${refusal}`, "Fix that and answer again."] : []),
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
|
82
|
+
export function agentPrompt(request, catalog, refusal) {
|
|
83
|
+
const transcript = request.messages.map((m) => `${m.role === "user" ? "USER" : "AGENT"}: ${m.content}`).join("\n\n");
|
|
84
|
+
return [
|
|
85
|
+
"You are the agent on the user's Ronana board. Answer their latest message.",
|
|
86
|
+
`You may propose up to ${request.max_cards} generation cards; each waits for the user's Confirm and spends nothing until then.`,
|
|
87
|
+
"Answer with ONE JSON object and nothing else: {\"message\": \"what the user reads\", \"cards\": [ ... ]}.",
|
|
88
|
+
"Each card follows CARD SCHEMA; modelId must come from MODELS.",
|
|
89
|
+
"", "MODELS:", catalog,
|
|
90
|
+
"", "CARD SCHEMA:", JSON.stringify(request.card_schema),
|
|
91
|
+
...(request.references.length ? ["", "ATTACHED:", JSON.stringify(request.references)] : []),
|
|
92
|
+
...(request.context ? ["", "CONTEXT:", request.context] : []),
|
|
93
|
+
"", "CONVERSATION:", transcript,
|
|
94
|
+
...(refusal ? ["", `Your last answer was refused: ${refusal}`, "Fix that and answer again."] : []),
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
const ENHANCE_TIMEOUT_MS = 120_000;
|
|
98
|
+
/** The least time between two empty waits. */
|
|
99
|
+
const IDLE_FLOOR_MS = 5_000;
|
|
100
|
+
// Inside the board's own wait for an answer.
|
|
101
|
+
const AGENT_TIMEOUT_MS = 150_000;
|
|
102
|
+
/** "Still working" every 15s while the AI writes. The studio treats a taken
|
|
103
|
+
* request that goes quiet for 45s as abandoned, so this is what lets a slow
|
|
104
|
+
* answer take its time. Best effort: a missed beat is not an error. */
|
|
105
|
+
const PROGRESS_EVERY_MS = 15_000;
|
|
106
|
+
async function answer(remote, runner, request, log) {
|
|
107
|
+
const beat = setInterval(() => { void callTool(remote, "request_progress", { id: request.id }).catch(() => { }); }, PROGRESS_EVERY_MS);
|
|
108
|
+
try {
|
|
109
|
+
await answerOnce(remote, runner, request, log);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
clearInterval(beat);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function answerOnce(remote, runner, request, log) {
|
|
116
|
+
let catalog = "";
|
|
117
|
+
if (request.kind === "agent") {
|
|
118
|
+
catalog = JSON.stringify(await callTool(remote, "agent_tool", { id: request.id, name: "list_models", input: {} }));
|
|
119
|
+
}
|
|
120
|
+
let refusal;
|
|
121
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
122
|
+
const prompt = request.kind === "enhance" ? enhancePrompt(request, refusal) : agentPrompt(request, catalog, refusal);
|
|
123
|
+
const output = await runOnce(runner, prompt, request.kind === "enhance" ? ENHANCE_TIMEOUT_MS : AGENT_TIMEOUT_MS);
|
|
124
|
+
let args;
|
|
125
|
+
if (request.kind === "enhance") {
|
|
126
|
+
args = { id: request.id, prompt: extractPrompt(output) };
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const json = extractJson(output);
|
|
130
|
+
if (!json) {
|
|
131
|
+
refusal = "it was not one JSON object.";
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
args = { id: request.id, message: typeof json.message === "string" ? json.message : "", cards: Array.isArray(json.cards) ? json.cards : [] };
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await callTool(remote, "reply", args);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (!(error instanceof ToolError) || error.error.code !== "INVALID_REPLY")
|
|
142
|
+
throw error;
|
|
143
|
+
refusal = error.message;
|
|
144
|
+
log(` refused (${refusal}); asking ${runner.app} to fix it`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`${runner.app} could not produce an answer the studio accepts${refusal ? `: ${refusal}` : "."}`);
|
|
148
|
+
}
|
|
149
|
+
/** Listen until Ctrl+C. */
|
|
150
|
+
export async function listen(remote, runner, log) {
|
|
151
|
+
log(`Listening as ${runner.app}. Ronana's studio now sends Enhance and agent requests here (Ctrl+C to stop).`);
|
|
152
|
+
let backoff = 0;
|
|
153
|
+
for (;;) {
|
|
154
|
+
try {
|
|
155
|
+
const asked = Date.now();
|
|
156
|
+
const { request } = await callTool(remote, "wait_for_requests", { app: runner.app });
|
|
157
|
+
backoff = 0;
|
|
158
|
+
if (!request) {
|
|
159
|
+
// The server answers at once (not after holding) when its budget of
|
|
160
|
+
// held calls is spent: pause instead of asking again in a tight loop.
|
|
161
|
+
if (Date.now() - asked < IDLE_FLOOR_MS)
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, IDLE_FLOOR_MS));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const started = Date.now();
|
|
166
|
+
log(`● ${request.kind} ${request.id}${request.kind === "enhance" ? ` · ${request.model.name}` : ""}`);
|
|
167
|
+
try {
|
|
168
|
+
await answer(remote, runner, request, log);
|
|
169
|
+
log(`✓ sent · ${((Date.now() - started) / 1000).toFixed(1)}s`);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
log(`✗ ${error instanceof Error ? error.message : String(error)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// A refused sign-in or a missing plan will not fix itself: stop.
|
|
177
|
+
if (error instanceof ToolError && (error.error.code === "SCOPE_REQUIRED" || error.error.code === "UNAUTHORIZED"))
|
|
178
|
+
throw error;
|
|
179
|
+
if (error instanceof Error && /sign.?in|401|pricing/i.test(error.message))
|
|
180
|
+
throw error;
|
|
181
|
+
backoff = Math.min(30_000, backoff ? backoff * 2 : 2_000);
|
|
182
|
+
log(`Connection problem (${error instanceof Error ? error.message : String(error)}). Retrying in ${backoff / 1000}s.`);
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|