From cfe7790d4e4b0611a2b74cd9d8d903927a647afb Mon Sep 17 00:00:00 2001 From: liyb Date: Mon, 12 Oct 2026 13:23:51 +0800 Subject: [PATCH] 0.3.6: Files lists what the run produced, not every URL in the data --- package-lock.json | 4 ++-- package.json | 2 +- server.json | 4 ++-- skills/sandbase/SKILL.md | 6 ++++-- skills/sandbase/skill.json | 2 +- src/agent/commands.ts | 39 ++++++++++++++++++++++++++++++-------- test/agent.test.ts | 24 +++++++++++++++++++++-- 7 files changed, 63 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11b4e87..859a164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sandbaseai/cli", - "version": "0.3.5", + "version": "0.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sandbaseai/cli", - "version": "0.3.5", + "version": "0.3.6", "license": "Apache-2.0", "dependencies": { "jsonc-parser": "3.3.1", diff --git a/package.json b/package.json index c606084..19ffab6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sandbaseai/cli", - "version": "0.3.5", + "version": "0.3.6", "mcpName": "io.github.sandbaseai/cli", "description": "Give 25 AI clients—Claude Code, Codex, Cursor, Hermes, OpenClaw and more—2,000+ models and APIs through one MCP server and an agent-friendly CLI", "type": "module", diff --git a/server.json b/server.json index 4f8b250..607701e 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.sandbaseai/cli", "title": "SandBase", "description": "Connect MCP clients to 2,000+ AI models without managing provider API keys.", - "version": "0.3.5", + "version": "0.3.6", "repository": { "url": "https://github.com/sandbaseai/cli", "source": "github" @@ -26,7 +26,7 @@ { "registryType": "npm", "identifier": "@sandbaseai/cli", - "version": "0.3.5", + "version": "0.3.6", "packageArguments": [ { "type": "positional", diff --git a/skills/sandbase/SKILL.md b/skills/sandbase/SKILL.md index f901fb3..65d6b94 100644 --- a/skills/sandbase/SKILL.md +++ b/skills/sandbase/SKILL.md @@ -1,6 +1,6 @@ --- name: sandbase -version: 0.3.5 +version: 0.3.6 description: >- Discover better ways to complete tasks. Proactively search SandBase (the sandbase_discover MCP tool, or `sandbase discover` in a shell) before writing a @@ -129,7 +129,7 @@ Always move both to the latest; never downgrade one to match the other. If they - Add `-j` for JSON output. Errors then look like `{"error": {"code", "message", "hint"}}`; follow `hint`. - `-w` waits with backoff (default `--timeout 300` seconds). If the run is still going when the timeout elapses, the CLI prints the `runs get` command to resume — use it rather than re-running (re-running is billed again). -- `-o` writes only the tool's output (no envelope). Files/URLs in the output are also listed in text mode. +- `-o` writes only the tool's output (no envelope). In text mode, `Files:` lists the files the run produced; URLs inside data results are in the output itself. - Exit code is `0` on success or still-running, `1` on failure (including a failed run), `2` on bad usage. - If an error mentions a `run_id`, the run already started: resume it with `sandbase runs get -r -w` — never re-run (that bills again). - To store an API key without exposing it: `printf %s "$KEY" | sandbase keys add -k -`. @@ -203,7 +203,9 @@ Do not use SandBase for purely local work (editing files, reasoning over code al - Chinese works and aliases are expanded: `推特`, `小红书`, `抖音`, `文生视频`, `图片生成`. - Narrow with `type` (`llm`, `api`, `multimodal`, `embedding`, `service`) or `vendor` (`openai`, `twitter`, `kling`). - Split multi-source tasks: one discover per source ("twitter posts", then "reddit posts"), then run each. +- If no tool matches every word, discover returns the closest matches with a hint saying so; check their descriptions before using one. - Zero results come with `hints`; read them before rephrasing. +- `inspect` hints name the inputs that take a file URL; for a local file use `sandbase upload` (§3). --- diff --git a/skills/sandbase/skill.json b/skills/sandbase/skill.json index 79a0f81..a106deb 100644 --- a/skills/sandbase/skill.json +++ b/skills/sandbase/skill.json @@ -1,6 +1,6 @@ { "name": "sandbase", - "version": "0.3.5", + "version": "0.3.6", "description": "Discover and run 2,000+ AI models and APIs (media generation, social data, search, scraping) through one MCP interface or CLI, paid per call from one balance.", "author": "SandBase AI", "license": "Apache-2.0", diff --git a/src/agent/commands.ts b/src/agent/commands.ts index 8e05402..eebb275 100644 --- a/src/agent/commands.ts +++ b/src/agent/commands.ts @@ -74,12 +74,35 @@ function hintLines(hints: unknown): string[] { return [String(hints)]; } function printHints(out: Output, ...sources: unknown[]): void { const lines = sources.flatMap(hintLines); if (lines.length) out.print(["", "Hints:", ...lines.map(line => ` - ${line}`)].join("\n")); } -function collectUrls(value: unknown, found = new Set()): Set { - if (found.size >= 20) return found; - if (typeof value === "string") { if (/^https?:\/\/\S+$/.test(value)) found.add(value); } - else if (Array.isArray(value)) for (const item of value) collectUrls(item, found); - else if (value && typeof value === "object") for (const item of Object.values(value)) collectUrls(item, found); - return found; +/** + * Files the run produced. The server returns media outputs as `[{url, …}]` + * (content_type is only added when it can be inferred), so an entry counts as a + * file when it has a `url` and carries no content of its own. A scraped page's + * `{url, markdown}`, a tweet row, or a nested avatar is content, not a file. + */ +export function generatedFiles(output: unknown): string[] { + const isUrl = (value: unknown): value is string => typeof value === "string" && /^https?:\/\/\S+$/.test(value); + // Keys that make an object a data row rather than a produced file. + const contentKeys = ["text", "markdown", "md", "html", "content", "title", "body", "snippet", "caption", "summary", "data", "items", "results", "children", "comments", "author", "user"]; + const fileEntry = (entry: unknown): string | undefined => { + if (isUrl(entry)) return entry; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined; + const record = entry as Record; + if (!isUrl(record.url)) return undefined; + return contentKeys.some(key => record[key] !== undefined) ? undefined : record.url as string; + }; + const list = (value: unknown): string[] => { + const entries = Array.isArray(value) ? value : [value]; + return entries.map(fileEntry).filter((url): url is string => !!url); + }; + let files = list(output); + // {images: [{url}], seed: 1} / {video: {url}}: a media-named key holds the files. + if (!files.length && output && typeof output === "object" && !Array.isArray(output)) { + for (const [key, value] of Object.entries(output)) { + if (/^(image|video|audio|file|media|clip|frame)s?$/i.test(key)) files = files.concat(list(value)); + } + } + return [...new Set(files)].slice(0, 20); } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } type OptionSpec = Record; @@ -171,8 +194,8 @@ function printRun(out: Output, view: RunView, outputFile?: string): void { if (view.idempotency_key) lines.push(`Idempotency key: ${view.idempotency_key} (reuse it with the same input when retrying this service run)`); if (view.cost === undefined && view.cost_status === "settling") lines.push("Cost: settling (see `sandbase runs list` shortly)"); if (phase === "succeeded" && hasOutput(view)) { - const urls = [...collectUrls(view.output)]; - if (urls.length) lines.push("Files:", ...urls.map(url => ` ${url}`)); + const files = generatedFiles(view.output); + if (files.length) lines.push("Files:", ...files.map(url => ` ${url}`)); if (outputFile) lines.push(`Saved output to ${outputFile}`); else { const text = typeof view.output === "string" ? view.output : JSON.stringify(view.output, null, 2); lines.push("Output:", text.length > 6000 ? `${text.slice(0, 6000)}\n…(truncated ${text.length - 6000} chars; rerun \`sandbase runs get -r ${view.run_id ?? ""} -o result.json\` to save everything)` : text); } } diff --git a/test/agent.test.ts b/test/agent.test.ts index dd84509..961da16 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -6,7 +6,7 @@ import { PassThrough } from "node:stream"; import { pathToFileURL } from "node:url"; import test from "node:test"; import { httpError, resolveEndpoint, SandBaseError, SandBaseMCPClient, type FetchLike } from "../src/agent/client.js"; -import { formatPrice, resolveSchema, runAgentCommand, uploadUrlFor } from "../src/agent/commands.js"; +import { formatPrice, generatedFiles, resolveSchema, runAgentCommand, uploadUrlFor } from "../src/agent/commands.js"; import { probeEndpoint } from "../src/agent/probe.js"; import { compareVersions, updateNotice } from "../src/agent/update.js"; @@ -331,7 +331,7 @@ test("setup refreshes the bridge and SandBase-owned Skills but leaves edited Ski const lines: string[] = []; assert.equal(await setup(new FileCredentialStore(env.SANDBASE_HOME!), line => void lines.push(line)), true); assert.match(lines.join("\n"), /bridge: status=updated/); assert.match(lines.join("\n"), /claude-code: skill=updated/); assert.match(lines.join("\n"), /kiro: skill=modified/); - assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.3\.5/); + assert.match(await readFile(join(claudeDir, "SKILL.md"), "utf8"), /version: 0\.3\.6/); assert.match(await readFile(join(kiroDir, "SKILL.md"), "utf8"), /user edit/); } finally { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } } }); @@ -380,3 +380,23 @@ test("upload follows SANDBASE_MCP_URL, maps 402/404, and rejects a response with const empty = await cli("upload", [png, "-j"], env, reply(200, "{}")); assert.equal(JSON.parse(empty.out).error.code, "INVALID_RESPONSE"); }); +test("Files lists what a run produced, not every URL inside data results", () => { + assert.deepEqual(generatedFiles([{ url: "https://cdn/x.mp4", content_type: "video/mp4" }]), ["https://cdn/x.mp4"]); + // Media entries carry any number of extra fields; a marker is what identifies a file. + assert.deepEqual(generatedFiles([{ url: "https://cdn/1.png", content_type: "image/png", file_name: "1.png", file_size: 12, width: 1024, height: 1024, seed: 7, nsfw: false }]), ["https://cdn/1.png"]); + assert.deepEqual(generatedFiles([{ url: "https://example.com/page", markdown: "# hi", html: "

hi

" }]), []); + // content_type is only present when the server could infer it from the URL. + assert.deepEqual(generatedFiles([{ url: "https://cdn/abc123?token=x" }]), ["https://cdn/abc123?token=x"]); + // One caption entry must not hide the file, and a long run is truncated, not dropped. + assert.deepEqual(generatedFiles([{ url: "https://cdn/a.mp4", content_type: "video/mp4" }, { text: "caption" }]), ["https://cdn/a.mp4"]); + assert.equal(generatedFiles(Array.from({ length: 25 }, (_, i) => ({ url: `https://cdn/${i}.png`, content_type: "image/png" }))).length, 20); + assert.deepEqual(generatedFiles({ urls: ["https://example.com/a", "https://example.com/b"] }), []); + assert.deepEqual(generatedFiles({ output: { url: "https://example.com/page", markdown: "x" } }), []); + assert.deepEqual(generatedFiles("https://cdn/a.png"), ["https://cdn/a.png"]); + assert.deepEqual(generatedFiles({ images: [{ url: "https://cdn/1.png" }, { url: "https://cdn/2.png" }], seed: 3 }), ["https://cdn/1.png", "https://cdn/2.png"]); + assert.deepEqual(generatedFiles({ video: { url: "https://cdn/v.mp4" } }), ["https://cdn/v.mp4"]); + assert.deepEqual(generatedFiles({ result: { url: "https://example.com/page", markdown: "x" } }), []); + const tweets = [{ id: "1", text: "hi", user: { avatar: "https://pbs/a.jpg" }, media: [{ url: "https://video/1.mp4" }] }]; + assert.deepEqual(generatedFiles(tweets), []); + assert.deepEqual(generatedFiles({ text: "see https://x.com" }), []); +});