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 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()
@@ -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()