idiotproof 0.5.0__py3-none-any.whl
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.
- idiotproof/__init__.py +38 -0
- idiotproof/cli.py +146 -0
- idiotproof/mcp_server.py +216 -0
- idiotproof/media.py +1373 -0
- idiotproof/py.typed +1 -0
- idiotproof/sdk.py +1248 -0
- idiotproof-0.5.0.dist-info/METADATA +364 -0
- idiotproof-0.5.0.dist-info/RECORD +10 -0
- idiotproof-0.5.0.dist-info/WHEEL +4 -0
- idiotproof-0.5.0.dist-info/entry_points.txt +2 -0
idiotproof/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The small public surface of the IDEA Agent Development Kit."""
|
|
2
|
+
|
|
3
|
+
from .media import (
|
|
4
|
+
CombinedMedia,
|
|
5
|
+
DownloadedMedia,
|
|
6
|
+
MediaConcatIncompatible,
|
|
7
|
+
MediaDimensionMismatch,
|
|
8
|
+
MediaDimensions,
|
|
9
|
+
download_and_concat,
|
|
10
|
+
)
|
|
11
|
+
from .sdk import (
|
|
12
|
+
BoundRuntimeTool,
|
|
13
|
+
ChatId,
|
|
14
|
+
Idea,
|
|
15
|
+
IdeaBatchError,
|
|
16
|
+
IdeaError,
|
|
17
|
+
MapItem,
|
|
18
|
+
RuntimeTool,
|
|
19
|
+
numbered_media,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BoundRuntimeTool",
|
|
24
|
+
"ChatId",
|
|
25
|
+
"CombinedMedia",
|
|
26
|
+
"DownloadedMedia",
|
|
27
|
+
"Idea",
|
|
28
|
+
"IdeaBatchError",
|
|
29
|
+
"IdeaError",
|
|
30
|
+
"MapItem",
|
|
31
|
+
"MediaConcatIncompatible",
|
|
32
|
+
"MediaDimensionMismatch",
|
|
33
|
+
"MediaDimensions",
|
|
34
|
+
"RuntimeTool",
|
|
35
|
+
"download_and_concat",
|
|
36
|
+
"numbered_media",
|
|
37
|
+
]
|
|
38
|
+
__version__ = "0.5.0"
|
idiotproof/cli.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Command-line and stdio MCP entry point for IDEA ADK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from .sdk import Idea, IdeaError, MapItem
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _json_input(value: str) -> dict[str, Any]:
|
|
16
|
+
text = Path(value[1:]).read_text() if value.startswith("@") else value
|
|
17
|
+
parsed = json.loads(text)
|
|
18
|
+
if not isinstance(parsed, dict):
|
|
19
|
+
raise ValueError("tool input must be a JSON object")
|
|
20
|
+
return parsed
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _map_item_json(item: MapItem) -> dict[str, Any]:
|
|
24
|
+
if item.error is None:
|
|
25
|
+
return {"index": item.index, "result": item.result}
|
|
26
|
+
return {
|
|
27
|
+
"index": item.index,
|
|
28
|
+
"error": {
|
|
29
|
+
"message": str(item.error),
|
|
30
|
+
"code": item.error.code,
|
|
31
|
+
"status_code": item.error.status_code,
|
|
32
|
+
"details": item.error.details,
|
|
33
|
+
"request_id": item.error.request_id,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parser() -> argparse.ArgumentParser:
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
prog="idea-adk",
|
|
41
|
+
description="Software for agents that create and edit video.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("--base-url", help="override IDEA_API_BASE_URL")
|
|
44
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
45
|
+
|
|
46
|
+
commands.add_parser("create-chat", help="create a new opaque chatId")
|
|
47
|
+
|
|
48
|
+
upload = commands.add_parser("upload-file", help="upload and normalize media")
|
|
49
|
+
upload.add_argument("chat_id")
|
|
50
|
+
upload.add_argument("path")
|
|
51
|
+
|
|
52
|
+
commands.add_parser("get-tools", help="print the live MCP catalog")
|
|
53
|
+
|
|
54
|
+
call = commands.add_parser("call", help="call one live tool by exact name")
|
|
55
|
+
call.add_argument("chat_id")
|
|
56
|
+
call.add_argument("tool")
|
|
57
|
+
call.add_argument("--input", default="{}", help="JSON object or @path/to/input.json")
|
|
58
|
+
|
|
59
|
+
mapped = commands.add_parser("map", help="call one live tool over many inputs")
|
|
60
|
+
mapped.add_argument("chat_id")
|
|
61
|
+
mapped.add_argument("tool")
|
|
62
|
+
mapped.add_argument("--input", required=True, help="batch JSON object or @path")
|
|
63
|
+
mapped.add_argument("--concurrency", type=int, default=4)
|
|
64
|
+
mapped.add_argument("--errors", choices=("raise", "collect"), default="raise")
|
|
65
|
+
|
|
66
|
+
publish = commands.add_parser("media-publish", help="publish a workspace media file")
|
|
67
|
+
publish.add_argument("chat_id")
|
|
68
|
+
publish.add_argument("media_path")
|
|
69
|
+
publish.add_argument("--ttl-seconds", type=int, default=7200)
|
|
70
|
+
|
|
71
|
+
commands.add_parser("mcp", help="run the stdio MCP server")
|
|
72
|
+
return parser
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _execute(args: argparse.Namespace) -> Any:
|
|
76
|
+
if args.command == "mcp":
|
|
77
|
+
from .mcp_server import serve
|
|
78
|
+
|
|
79
|
+
await serve(base_url=args.base_url)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
async with Idea(base_url=args.base_url) as idea:
|
|
83
|
+
if args.command == "create-chat":
|
|
84
|
+
return {"chatId": await idea.create_chat()}
|
|
85
|
+
if args.command == "upload-file":
|
|
86
|
+
return await idea.upload_file(args.chat_id, args.path)
|
|
87
|
+
if args.command == "get-tools":
|
|
88
|
+
tools = await idea.get_tools()
|
|
89
|
+
return {"tools": list(tools.catalog)}
|
|
90
|
+
if args.command == "call":
|
|
91
|
+
tools = await idea.get_tools()
|
|
92
|
+
return await tools.call(args.tool, args.chat_id, _json_input(args.input))
|
|
93
|
+
if args.command == "map":
|
|
94
|
+
batch = _json_input(args.input)
|
|
95
|
+
inputs = batch.get("inputs")
|
|
96
|
+
common = batch.get("common")
|
|
97
|
+
if not isinstance(inputs, list):
|
|
98
|
+
raise ValueError("map input must contain an inputs array")
|
|
99
|
+
if common is not None and not isinstance(common, dict):
|
|
100
|
+
raise ValueError("map common must be an object")
|
|
101
|
+
tools = await idea.get_tools()
|
|
102
|
+
results = await tools.map(
|
|
103
|
+
args.tool,
|
|
104
|
+
args.chat_id,
|
|
105
|
+
inputs,
|
|
106
|
+
common=common,
|
|
107
|
+
concurrency=args.concurrency,
|
|
108
|
+
errors=args.errors,
|
|
109
|
+
)
|
|
110
|
+
if args.errors == "collect":
|
|
111
|
+
items = cast(list[MapItem], results)
|
|
112
|
+
return {"items": [_map_item_json(item) for item in items]}
|
|
113
|
+
return {"results": results}
|
|
114
|
+
if args.command == "media-publish":
|
|
115
|
+
return await idea.media_publish(
|
|
116
|
+
args.chat_id,
|
|
117
|
+
args.media_path,
|
|
118
|
+
ttl_seconds=args.ttl_seconds,
|
|
119
|
+
)
|
|
120
|
+
raise AssertionError(f"unhandled command: {args.command}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv: list[str] | None = None) -> None:
|
|
124
|
+
"""Run the command-line interface."""
|
|
125
|
+
|
|
126
|
+
args = _parser().parse_args(argv)
|
|
127
|
+
try:
|
|
128
|
+
result = asyncio.run(_execute(args))
|
|
129
|
+
except (IdeaError, OSError, TypeError, ValueError, json.JSONDecodeError) as error:
|
|
130
|
+
payload: dict[str, Any] = {"error": {"message": str(error)}}
|
|
131
|
+
if isinstance(error, IdeaError):
|
|
132
|
+
payload["error"].update(
|
|
133
|
+
{
|
|
134
|
+
"code": error.code,
|
|
135
|
+
"status_code": error.status_code,
|
|
136
|
+
"request_id": error.request_id,
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
print(json.dumps(payload), file=sys.stderr)
|
|
140
|
+
raise SystemExit(1) from error
|
|
141
|
+
if result is not None:
|
|
142
|
+
print(json.dumps(result, indent=2))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main()
|
idiotproof/mcp_server.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""A dependency-free stdio MCP adapter over :mod:`idiotproof`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .sdk import Idea, IdeaError, Json, _tool_attribute
|
|
12
|
+
|
|
13
|
+
_LATEST_PROTOCOL = "2025-11-25"
|
|
14
|
+
_SUPPORTED_PROTOCOLS = {"2024-11-05", "2025-03-26", "2025-06-18", _LATEST_PROTOCOL}
|
|
15
|
+
_INSTRUCTIONS = """This ADK is software for agents, intended for one-off scripts and tool calls—not
|
|
16
|
+
as a stable dependency for persistent applications. Use one create_chat result for an entire
|
|
17
|
+
workspace. Uploads are normalized to 600 frames at 3 megapixels; upload_file automatically splits a
|
|
18
|
+
longer video into ordered, reserved <=600-frame parts. Assign batch, asset, and part sequence
|
|
19
|
+
metadata before concurrent uploads; completion order is never media order. For novel effects, map
|
|
20
|
+
variants over one representative preview capped near 590 frames before rendering every segment.
|
|
21
|
+
Use FFmpeg for cuts, joins, codecs, and audio. Use discovered
|
|
22
|
+
VLM/frame tools to inspect video and return grounding coordinates in [0,1000). Use Bobbie for GLSL,
|
|
23
|
+
ML, and CUDA vision pipelines; bindings are assigned automatically. Independent tool calls may run
|
|
24
|
+
in parallel. After
|
|
25
|
+
discovering tools in Python, use partial() to bind shared arguments and map() for ordered, bounded
|
|
26
|
+
concurrency over varying inputs. Map does not retry tool calls, and remote work already accepted
|
|
27
|
+
may continue after local cancellation. After media_publish, transiently embed the signed URL in
|
|
28
|
+
HTML generated by an in-memory viewer, serve it only on 127.0.0.1, and open or provide the localhost
|
|
29
|
+
URL. Do not download merely to preview. Do not print the signed URL or write it to HTML, manifests,
|
|
30
|
+
or logs; stop the viewer after review.
|
|
31
|
+
For durable output, use download_media or ordered download_and_concat; both keep signed URLs out of
|
|
32
|
+
resume state and replace final paths only after verification. Save important media because
|
|
33
|
+
workspaces are deleted aggressively."""
|
|
34
|
+
|
|
35
|
+
_FIXED_TOOLS: list[Json] = [
|
|
36
|
+
{
|
|
37
|
+
"name": "create_chat",
|
|
38
|
+
"description": (
|
|
39
|
+
"Create an opaque chatId. Reuse it for every upload and tool call in one workspace."
|
|
40
|
+
),
|
|
41
|
+
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "upload_file",
|
|
45
|
+
"description": "Upload and normalize one local image or video into an IDEA workspace.",
|
|
46
|
+
"inputSchema": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"chatId": {"type": "string", "description": "ID returned by create_chat."},
|
|
50
|
+
"path": {"type": "string", "description": "Local media file path."},
|
|
51
|
+
},
|
|
52
|
+
"required": ["chatId", "path"],
|
|
53
|
+
"additionalProperties": False,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "get_tools",
|
|
58
|
+
"description": "Return the server's current MCP tool descriptions and JSON Schemas.",
|
|
59
|
+
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _dynamic_definition(item: Mapping[str, Any]) -> Json | None:
|
|
65
|
+
tool_id = item.get("id")
|
|
66
|
+
if not isinstance(tool_id, str) or not tool_id:
|
|
67
|
+
return None
|
|
68
|
+
schema_value = item.get("input_schema")
|
|
69
|
+
schema: Json = dict(schema_value) if isinstance(schema_value, Mapping) else {"type": "object"}
|
|
70
|
+
properties_value = schema.get("properties")
|
|
71
|
+
properties = dict(properties_value) if isinstance(properties_value, Mapping) else {}
|
|
72
|
+
properties = {
|
|
73
|
+
"chatId": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"description": "The workspace chatId returned by create_chat.",
|
|
76
|
+
},
|
|
77
|
+
**properties,
|
|
78
|
+
}
|
|
79
|
+
required_value = schema.get("required")
|
|
80
|
+
required = list(required_value) if isinstance(required_value, list) else []
|
|
81
|
+
if "chatId" not in required:
|
|
82
|
+
required.insert(0, "chatId")
|
|
83
|
+
schema.update({"type": "object", "properties": properties, "required": required})
|
|
84
|
+
return {
|
|
85
|
+
"name": _tool_attribute(tool_id),
|
|
86
|
+
"description": str(item.get("description") or ""),
|
|
87
|
+
"inputSchema": schema,
|
|
88
|
+
"_idea_tool_id": tool_id,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _Server:
|
|
93
|
+
def __init__(self, idea: Idea) -> None:
|
|
94
|
+
self.idea = idea
|
|
95
|
+
self.dynamic: dict[str, str] = {}
|
|
96
|
+
|
|
97
|
+
async def tools(self) -> list[Json]:
|
|
98
|
+
result = [dict(item) for item in _FIXED_TOOLS]
|
|
99
|
+
fixed_names = {str(item["name"]) for item in result}
|
|
100
|
+
try:
|
|
101
|
+
catalog = await self.idea.get_tools(refresh=True)
|
|
102
|
+
except IdeaError:
|
|
103
|
+
return result
|
|
104
|
+
dynamic: dict[str, str] = {}
|
|
105
|
+
for item in catalog:
|
|
106
|
+
definition = _dynamic_definition(item)
|
|
107
|
+
if definition is None:
|
|
108
|
+
continue
|
|
109
|
+
name = str(definition["name"])
|
|
110
|
+
if name in fixed_names or name in dynamic:
|
|
111
|
+
continue
|
|
112
|
+
dynamic[name] = str(definition.pop("_idea_tool_id"))
|
|
113
|
+
result.append(definition)
|
|
114
|
+
self.dynamic = dynamic
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
async def call(self, name: str, arguments: Mapping[str, Any]) -> Json:
|
|
118
|
+
values = dict(arguments)
|
|
119
|
+
if name == "create_chat":
|
|
120
|
+
return {"chatId": await self.idea.create_chat()}
|
|
121
|
+
if name == "get_tools":
|
|
122
|
+
tools = await self.idea.get_tools(refresh=True)
|
|
123
|
+
return {"tools": list(tools.catalog)}
|
|
124
|
+
if name == "upload_file":
|
|
125
|
+
chat_id = values.pop("chatId")
|
|
126
|
+
path = values.pop("path")
|
|
127
|
+
if values:
|
|
128
|
+
raise IdeaError("upload_file received unknown arguments", code="invalid_tool_input")
|
|
129
|
+
return await self.idea.upload_file(str(chat_id), str(path))
|
|
130
|
+
if not self.dynamic:
|
|
131
|
+
await self.tools()
|
|
132
|
+
tool_id = self.dynamic.get(name)
|
|
133
|
+
if tool_id is None:
|
|
134
|
+
raise IdeaError(f"unknown tool: {name}", code="unknown_tool")
|
|
135
|
+
try:
|
|
136
|
+
chat_id = values.pop("chatId")
|
|
137
|
+
except KeyError as error:
|
|
138
|
+
raise IdeaError("chatId is required", code="invalid_tool_input") from error
|
|
139
|
+
tools = await self.idea.get_tools()
|
|
140
|
+
return await tools.call(tool_id, str(chat_id), values)
|
|
141
|
+
|
|
142
|
+
async def dispatch(self, request: Mapping[str, Any]) -> Json | None:
|
|
143
|
+
request_id = request.get("id")
|
|
144
|
+
method = request.get("method")
|
|
145
|
+
if request_id is None:
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
if method == "initialize":
|
|
149
|
+
params = request.get("params")
|
|
150
|
+
requested = params.get("protocolVersion") if isinstance(params, Mapping) else None
|
|
151
|
+
result: Any = {
|
|
152
|
+
"protocolVersion": (
|
|
153
|
+
requested if requested in _SUPPORTED_PROTOCOLS else _LATEST_PROTOCOL
|
|
154
|
+
),
|
|
155
|
+
"capabilities": {"tools": {}},
|
|
156
|
+
"serverInfo": {"name": "idea-adk", "version": "0.5.0"},
|
|
157
|
+
"instructions": _INSTRUCTIONS,
|
|
158
|
+
}
|
|
159
|
+
elif method == "ping":
|
|
160
|
+
result = {}
|
|
161
|
+
elif method == "tools/list":
|
|
162
|
+
result = {"tools": await self.tools()}
|
|
163
|
+
elif method == "tools/call":
|
|
164
|
+
params = request.get("params")
|
|
165
|
+
if not isinstance(params, Mapping) or not isinstance(params.get("name"), str):
|
|
166
|
+
raise IdeaError("tools/call requires a name", code="invalid_tool_input")
|
|
167
|
+
args = params.get("arguments", {})
|
|
168
|
+
if not isinstance(args, Mapping):
|
|
169
|
+
raise IdeaError("tool arguments must be an object", code="invalid_tool_input")
|
|
170
|
+
try:
|
|
171
|
+
output = await self.call(str(params["name"]), args)
|
|
172
|
+
result = {
|
|
173
|
+
"content": [{"type": "text", "text": json.dumps(output)}],
|
|
174
|
+
"structuredContent": output,
|
|
175
|
+
"isError": False,
|
|
176
|
+
}
|
|
177
|
+
except (IdeaError, KeyError, TypeError, ValueError) as error:
|
|
178
|
+
result = {
|
|
179
|
+
"content": [{"type": "text", "text": str(error)}],
|
|
180
|
+
"isError": True,
|
|
181
|
+
}
|
|
182
|
+
else:
|
|
183
|
+
return {
|
|
184
|
+
"jsonrpc": "2.0",
|
|
185
|
+
"id": request_id,
|
|
186
|
+
"error": {"code": -32601, "message": f"method not found: {method}"},
|
|
187
|
+
}
|
|
188
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
189
|
+
except Exception as error:
|
|
190
|
+
return {
|
|
191
|
+
"jsonrpc": "2.0",
|
|
192
|
+
"id": request_id,
|
|
193
|
+
"error": {"code": -32603, "message": str(error)},
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def serve(*, base_url: str | None = None) -> None:
|
|
198
|
+
"""Serve newline-delimited MCP JSON-RPC over stdin/stdout."""
|
|
199
|
+
|
|
200
|
+
async with Idea(base_url=base_url) as idea:
|
|
201
|
+
server = _Server(idea)
|
|
202
|
+
while line := await asyncio.to_thread(sys.stdin.buffer.readline):
|
|
203
|
+
try:
|
|
204
|
+
request = json.loads(line)
|
|
205
|
+
if not isinstance(request, dict):
|
|
206
|
+
raise ValueError("request must be an object")
|
|
207
|
+
response = await server.dispatch(request)
|
|
208
|
+
except (json.JSONDecodeError, ValueError) as error:
|
|
209
|
+
response = {
|
|
210
|
+
"jsonrpc": "2.0",
|
|
211
|
+
"id": None,
|
|
212
|
+
"error": {"code": -32700, "message": str(error)},
|
|
213
|
+
}
|
|
214
|
+
if response is not None:
|
|
215
|
+
sys.stdout.write(json.dumps(response, separators=(",", ":")) + "\n")
|
|
216
|
+
sys.stdout.flush()
|