vision-electronic-indexing-pi 0.1.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/.pi/extensions/vision-inventory-mcp/README.md +39 -0
- package/.pi/extensions/vision-inventory-mcp/index.ts +526 -0
- package/.pi/prompts/vision-inventory-agent-bom.md +13 -0
- package/.pi/skills/vision-inventory-workflow/SKILL.md +38 -0
- package/LICENSE +21 -0
- package/README.md +481 -0
- package/package.json +45 -0
- package/requirements.txt +6 -0
- package/scripts/inventory_folder_to_csv.py +477 -0
- package/vision_inventory_mcp.py +859 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Vision Inventory MCP Pi Extension
|
|
2
|
+
|
|
3
|
+
Pi extension that bridges the bundled `vision_inventory_mcp.py` and `scripts/inventory_folder_to_csv.py` into native Pi tools and commands.
|
|
4
|
+
|
|
5
|
+
This repo can be used project-locally or installed as a Pi package. The package intentionally does **not** bundle Python dependencies or a web-search/browser dependency.
|
|
6
|
+
|
|
7
|
+
## Exposed Pi tools
|
|
8
|
+
|
|
9
|
+
- `vision_inventory_process_image` → MCP `process_image`
|
|
10
|
+
- `vision_inventory_process_folder` → MCP `process_image_folder`
|
|
11
|
+
- `vision_inventory_save` → MCP `save_inventory`
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
/vision-inventory-setup
|
|
17
|
+
/vision-inventory-credentials
|
|
18
|
+
/vision-inventory-restart
|
|
19
|
+
/vision-inventory-bom <image_folder> <output_dir> [options]
|
|
20
|
+
/vision-inventory-agent-bom <image_folder> <output_dir> [options]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`/vision-inventory-setup` prompts for Cloudflare credentials the first time and stores them in:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
~/.pi/agent/vision-inventory/credentials.json
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Use `/vision-inventory-credentials` to change them later.
|
|
30
|
+
|
|
31
|
+
`/vision-inventory-agent-bom` starts an agent turn that runs the image workflow, performs datasheet enrichment with whatever web-search/browser tool or skill the user has installed, writes `datasheet_cache.json`, reruns with `--skip-vision`, and summarizes uncertainties.
|
|
32
|
+
|
|
33
|
+
## External dependencies not bundled
|
|
34
|
+
|
|
35
|
+
- Python packages: `mcp`, `requests`, `pillow`, `python-dotenv`; optional `pillow-heif`.
|
|
36
|
+
- A Pi web-search/browser tool or skill for datasheet lookup.
|
|
37
|
+
- Cloudflare Workers AI credentials.
|
|
38
|
+
|
|
39
|
+
The setup command can check/install Python dependencies, but web-search/browser capability must be installed/enabled separately.
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const SERVER_FILE = "vision_inventory_mcp.py";
|
|
12
|
+
const PYTHON_COMMAND = process.env.PI_VISION_INVENTORY_PYTHON || "python3";
|
|
13
|
+
const MAX_RESULT_CHARS = 50_000;
|
|
14
|
+
const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const CONFIG_DIR = join(homedir(), ".pi", "agent", "vision-inventory");
|
|
16
|
+
const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
|
|
17
|
+
|
|
18
|
+
type VisionCredentials = {
|
|
19
|
+
cloudflareAccountId?: string;
|
|
20
|
+
cloudflareAuthToken?: string;
|
|
21
|
+
workersAiModel?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type JsonRpcResponse = {
|
|
25
|
+
jsonrpc: "2.0";
|
|
26
|
+
id: number;
|
|
27
|
+
result?: unknown;
|
|
28
|
+
error?: { code?: number; message?: string; data?: unknown };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type PendingRequest = {
|
|
32
|
+
resolve: (value: unknown) => void;
|
|
33
|
+
reject: (error: Error) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
class McpStdioClient {
|
|
37
|
+
private proc: ChildProcessWithoutNullStreams | undefined;
|
|
38
|
+
private nextId = 1;
|
|
39
|
+
private buffer = "";
|
|
40
|
+
private pending = new Map<number, PendingRequest>();
|
|
41
|
+
private initialized = false;
|
|
42
|
+
private startPromise: Promise<void> | undefined;
|
|
43
|
+
|
|
44
|
+
constructor(private readonly packageRoot: string) {}
|
|
45
|
+
|
|
46
|
+
async start(): Promise<void> {
|
|
47
|
+
if (this.initialized) return;
|
|
48
|
+
if (this.startPromise) return this.startPromise;
|
|
49
|
+
|
|
50
|
+
this.startPromise = this.startInternal();
|
|
51
|
+
return this.startPromise;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async startInternal(): Promise<void> {
|
|
55
|
+
const serverPath = join(this.packageRoot, SERVER_FILE);
|
|
56
|
+
if (!existsSync(serverPath)) {
|
|
57
|
+
throw new Error(`Cannot find ${SERVER_FILE} in ${this.packageRoot}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.proc = spawn(PYTHON_COMMAND, [serverPath], {
|
|
61
|
+
cwd: this.packageRoot,
|
|
62
|
+
env: await buildPythonEnv(),
|
|
63
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.proc.stdout.setEncoding("utf8");
|
|
67
|
+
this.proc.stderr.setEncoding("utf8");
|
|
68
|
+
|
|
69
|
+
this.proc.stdout.on("data", (chunk: string) => this.onStdout(chunk));
|
|
70
|
+
this.proc.stderr.on("data", (chunk: string) => {
|
|
71
|
+
// MCP servers should not write protocol data to stderr. Keep it visible in `details` on failures.
|
|
72
|
+
this.lastStderr = (this.lastStderr + chunk).slice(-10_000);
|
|
73
|
+
});
|
|
74
|
+
this.proc.on("error", (error) => {
|
|
75
|
+
const err = new Error(`Failed to start Vision Inventory MCP server with ${PYTHON_COMMAND}: ${error.message}`);
|
|
76
|
+
for (const pending of this.pending.values()) pending.reject(err);
|
|
77
|
+
this.pending.clear();
|
|
78
|
+
this.initialized = false;
|
|
79
|
+
this.startPromise = undefined;
|
|
80
|
+
this.proc = undefined;
|
|
81
|
+
});
|
|
82
|
+
this.proc.on("exit", (code, signal) => {
|
|
83
|
+
const err = new Error(`Vision Inventory MCP server exited with code ${code ?? "null"}${signal ? ` signal ${signal}` : ""}`);
|
|
84
|
+
for (const pending of this.pending.values()) pending.reject(err);
|
|
85
|
+
this.pending.clear();
|
|
86
|
+
this.initialized = false;
|
|
87
|
+
this.startPromise = undefined;
|
|
88
|
+
this.proc = undefined;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await this.request("initialize", {
|
|
92
|
+
protocolVersion: "2024-11-05",
|
|
93
|
+
capabilities: {},
|
|
94
|
+
clientInfo: { name: "pi-vision-inventory-extension", version: "1.0.0" },
|
|
95
|
+
});
|
|
96
|
+
this.notify("notifications/initialized", {});
|
|
97
|
+
this.initialized = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private lastStderr = "";
|
|
101
|
+
|
|
102
|
+
getStderr(): string {
|
|
103
|
+
return this.lastStderr;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async stop(): Promise<void> {
|
|
107
|
+
const proc = this.proc;
|
|
108
|
+
if (!proc) return;
|
|
109
|
+
this.proc = undefined;
|
|
110
|
+
this.initialized = false;
|
|
111
|
+
this.startPromise = undefined;
|
|
112
|
+
proc.kill("SIGTERM");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
|
116
|
+
await this.start();
|
|
117
|
+
return this.request("tools/call", { name, arguments: args });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private request(method: string, params: unknown): Promise<unknown> {
|
|
121
|
+
if (!this.proc) throw new Error("MCP server is not running");
|
|
122
|
+
const id = this.nextId++;
|
|
123
|
+
const message = { jsonrpc: "2.0", id, method, params };
|
|
124
|
+
const payload = JSON.stringify(message) + "\n";
|
|
125
|
+
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
this.pending.set(id, { resolve, reject });
|
|
128
|
+
this.proc!.stdin.write(payload, "utf8", (error) => {
|
|
129
|
+
if (error) {
|
|
130
|
+
this.pending.delete(id);
|
|
131
|
+
reject(error);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private notify(method: string, params: unknown): void {
|
|
138
|
+
if (!this.proc) return;
|
|
139
|
+
this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n", "utf8");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private onStdout(chunk: string): void {
|
|
143
|
+
this.buffer += chunk;
|
|
144
|
+
while (true) {
|
|
145
|
+
const newline = this.buffer.indexOf("\n");
|
|
146
|
+
if (newline < 0) return;
|
|
147
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
148
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
149
|
+
if (!line) continue;
|
|
150
|
+
|
|
151
|
+
let message: JsonRpcResponse;
|
|
152
|
+
try {
|
|
153
|
+
message = JSON.parse(line) as JsonRpcResponse;
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (typeof message.id !== "number") continue;
|
|
159
|
+
const pending = this.pending.get(message.id);
|
|
160
|
+
if (!pending) continue;
|
|
161
|
+
this.pending.delete(message.id);
|
|
162
|
+
|
|
163
|
+
if (message.error) {
|
|
164
|
+
pending.reject(new Error(message.error.message ?? `MCP error ${message.error.code ?? "unknown"}`));
|
|
165
|
+
} else {
|
|
166
|
+
pending.resolve(message.result);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function findPackageRoot(startDir: string, fallback: string): string {
|
|
173
|
+
let current = startDir;
|
|
174
|
+
while (true) {
|
|
175
|
+
if (existsSync(join(current, SERVER_FILE)) && existsSync(join(current, "scripts", "inventory_folder_to_csv.py"))) {
|
|
176
|
+
return current;
|
|
177
|
+
}
|
|
178
|
+
const parent = dirname(current);
|
|
179
|
+
if (parent === current) return fallback;
|
|
180
|
+
current = parent;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveUserPath(cwd: string, value: unknown): unknown {
|
|
185
|
+
if (typeof value !== "string" || value.length === 0) return value;
|
|
186
|
+
return isAbsolute(value) ? value : resolve(cwd, value);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function loadCredentials(): Promise<VisionCredentials> {
|
|
190
|
+
try {
|
|
191
|
+
return JSON.parse(await readFile(CREDENTIALS_FILE, "utf8")) as VisionCredentials;
|
|
192
|
+
} catch {
|
|
193
|
+
return {};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function saveCredentials(credentials: VisionCredentials): Promise<void> {
|
|
198
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
199
|
+
await writeFile(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), "utf8");
|
|
200
|
+
try {
|
|
201
|
+
await chmod(CREDENTIALS_FILE, 0o600);
|
|
202
|
+
} catch {
|
|
203
|
+
// Best effort on platforms/filesystems that do not support chmod.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function buildPythonEnv(): Promise<NodeJS.ProcessEnv> {
|
|
208
|
+
const credentials = await loadCredentials();
|
|
209
|
+
return {
|
|
210
|
+
...process.env,
|
|
211
|
+
CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID || credentials.cloudflareAccountId || "",
|
|
212
|
+
CLOUDFLARE_AUTH_TOKEN: process.env.CLOUDFLARE_AUTH_TOKEN || process.env.CLOUDFLARE_API_TOKEN || credentials.cloudflareAuthToken || "",
|
|
213
|
+
CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN || process.env.CLOUDFLARE_AUTH_TOKEN || credentials.cloudflareAuthToken || "",
|
|
214
|
+
WORKERS_AI_MODEL: process.env.WORKERS_AI_MODEL || credentials.workersAiModel || "@cf/meta/llama-4-scout-17b-16e-instruct",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function extractMcpPayload(result: unknown): unknown {
|
|
219
|
+
const maybe = result as { content?: Array<{ type?: string; text?: string }>; structuredContent?: unknown } | null;
|
|
220
|
+
if (maybe && typeof maybe === "object") {
|
|
221
|
+
if (maybe.structuredContent !== undefined) return maybe.structuredContent;
|
|
222
|
+
const text = maybe.content?.find((item) => item.type === "text" && typeof item.text === "string")?.text;
|
|
223
|
+
if (text !== undefined) {
|
|
224
|
+
try {
|
|
225
|
+
return JSON.parse(text);
|
|
226
|
+
} catch {
|
|
227
|
+
return text;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function resultText(payload: unknown): string {
|
|
235
|
+
const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
|
|
236
|
+
if (text.length <= MAX_RESULT_CHARS) return text;
|
|
237
|
+
return `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated to ${MAX_RESULT_CHARS} characters]`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function splitCommandArgs(input: string): string[] {
|
|
241
|
+
const args: string[] = [];
|
|
242
|
+
let current = "";
|
|
243
|
+
let quote: '"' | "'" | undefined;
|
|
244
|
+
let escaping = false;
|
|
245
|
+
|
|
246
|
+
for (const char of input) {
|
|
247
|
+
if (escaping) {
|
|
248
|
+
current += char;
|
|
249
|
+
escaping = false;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (char === "\\") {
|
|
253
|
+
escaping = true;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (quote) {
|
|
257
|
+
if (char === quote) quote = undefined;
|
|
258
|
+
else current += char;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (char === '"' || char === "'") {
|
|
262
|
+
quote = char;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (/\s/.test(char)) {
|
|
266
|
+
if (current) {
|
|
267
|
+
args.push(current);
|
|
268
|
+
current = "";
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
current += char;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (escaping) current += "\\";
|
|
276
|
+
if (current) args.push(current);
|
|
277
|
+
return args;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function normalizeWorkflowArgs(userCwd: string, args: string[]): string[] {
|
|
281
|
+
if (args.length < 2) return args;
|
|
282
|
+
return [resolveUserPath(userCwd, args[0]) as string, resolveUserPath(userCwd, args[1]) as string, ...args.slice(2)];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function runBatchWorkflow(packageRoot: string, userCwd: string, argsLine: string): Promise<string> {
|
|
286
|
+
const args = normalizeWorkflowArgs(userCwd, splitCommandArgs(argsLine));
|
|
287
|
+
if (args.length < 2) {
|
|
288
|
+
return [
|
|
289
|
+
"Usage:",
|
|
290
|
+
" /vision-inventory-bom <image_folder> <output_dir> [options]",
|
|
291
|
+
"",
|
|
292
|
+
"Examples:",
|
|
293
|
+
" /vision-inventory-bom ./photos ./output",
|
|
294
|
+
" /vision-inventory-bom ./photos ./output --recursive",
|
|
295
|
+
" /vision-inventory-bom ./photos ./output --skip-vision",
|
|
296
|
+
"",
|
|
297
|
+
"Options are forwarded to scripts/inventory_folder_to_csv.py.",
|
|
298
|
+
].join("\n");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const scriptPath = join(packageRoot, "scripts", "inventory_folder_to_csv.py");
|
|
302
|
+
if (!existsSync(scriptPath)) {
|
|
303
|
+
throw new Error(`Cannot find batch workflow script: ${scriptPath}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const env = await buildPythonEnv();
|
|
307
|
+
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
const proc = spawn(PYTHON_COMMAND, [scriptPath, ...args], {
|
|
310
|
+
cwd: packageRoot,
|
|
311
|
+
env,
|
|
312
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
let stdout = "";
|
|
316
|
+
let stderr = "";
|
|
317
|
+
proc.stdout.setEncoding("utf8");
|
|
318
|
+
proc.stderr.setEncoding("utf8");
|
|
319
|
+
proc.stdout.on("data", (chunk: string) => { stdout += chunk; });
|
|
320
|
+
proc.stderr.on("data", (chunk: string) => { stderr += chunk; });
|
|
321
|
+
proc.on("error", reject);
|
|
322
|
+
proc.on("exit", (code) => {
|
|
323
|
+
const output = `${stdout}${stderr ? `\nSTDERR:\n${stderr}` : ""}`.trim();
|
|
324
|
+
if (code === 0) resolve(output || "Vision inventory BOM workflow completed.");
|
|
325
|
+
else reject(new Error(`Workflow exited with code ${code}.\n${output}`));
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export default function (pi: ExtensionAPI) {
|
|
331
|
+
const packageRoot = findPackageRoot(EXTENSION_DIR, process.cwd());
|
|
332
|
+
let client: McpStdioClient | undefined;
|
|
333
|
+
|
|
334
|
+
function getClient(): McpStdioClient {
|
|
335
|
+
if (!client) client = new McpStdioClient(packageRoot);
|
|
336
|
+
return client;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function callVisionTool(ctx: { cwd: string }, name: string, args: Record<string, unknown>) {
|
|
340
|
+
const normalizedArgs = { ...args };
|
|
341
|
+
if (name === "process_image") normalizedArgs.image_path = resolveUserPath(ctx.cwd, normalizedArgs.image_path);
|
|
342
|
+
if (name === "process_image_folder") normalizedArgs.folder_path = resolveUserPath(ctx.cwd, normalizedArgs.folder_path);
|
|
343
|
+
if (name === "save_inventory") normalizedArgs.output_path = resolveUserPath(ctx.cwd, normalizedArgs.output_path);
|
|
344
|
+
|
|
345
|
+
const mcp = getClient();
|
|
346
|
+
const raw = await mcp.callTool(name, normalizedArgs);
|
|
347
|
+
const payload = extractMcpPayload(raw);
|
|
348
|
+
return {
|
|
349
|
+
content: [{ type: "text" as const, text: resultText(payload) }],
|
|
350
|
+
details: { tool: name, result: payload, stderr: mcp.getStderr() },
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
pi.registerTool({
|
|
355
|
+
name: "vision_inventory_process_image",
|
|
356
|
+
label: "Vision Inventory: Process Image",
|
|
357
|
+
description: "Analyze one electronics/PCB image with the local Vision Inventory MCP server and return structured visible inventory JSON.",
|
|
358
|
+
promptSnippet: "Analyze one electronics/PCB image into structured visual inventory JSON.",
|
|
359
|
+
promptGuidelines: [
|
|
360
|
+
"Use vision_inventory_process_image when the user asks to inventory or inspect a single electronics/PCB image in this repository.",
|
|
361
|
+
"vision_inventory_process_image only extracts visible markings; do separate lookup/validation after it returns if the user asks for enrichment.",
|
|
362
|
+
],
|
|
363
|
+
parameters: Type.Object({
|
|
364
|
+
image_path: Type.String({ description: "Path to the image file, relative to the project root or absolute." }),
|
|
365
|
+
max_side: Type.Optional(Type.Integer({ description: "Maximum resized image side before submission.", default: 4000 })),
|
|
366
|
+
jpeg_quality: Type.Optional(Type.Integer({ description: "JPEG conversion quality from 1 to 100.", default: 96 })),
|
|
367
|
+
custom_prompt: Type.Optional(Type.String({ description: "Optional custom analysis prompt." })),
|
|
368
|
+
}),
|
|
369
|
+
async execute(_id, params, _signal, onUpdate, ctx) {
|
|
370
|
+
onUpdate?.({ content: [{ type: "text", text: "Starting Vision Inventory MCP server and processing image..." }], details: {} });
|
|
371
|
+
return callVisionTool(ctx, "process_image", params as Record<string, unknown>);
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
pi.registerTool({
|
|
376
|
+
name: "vision_inventory_process_folder",
|
|
377
|
+
label: "Vision Inventory: Process Folder",
|
|
378
|
+
description: "Analyze all supported electronics/PCB images in a folder with the local Vision Inventory MCP server.",
|
|
379
|
+
promptSnippet: "Batch-analyze a folder of electronics/PCB images into structured inventory JSON.",
|
|
380
|
+
promptGuidelines: [
|
|
381
|
+
"Use vision_inventory_process_folder when the user asks to inventory a folder or batch of electronics/PCB images.",
|
|
382
|
+
],
|
|
383
|
+
parameters: Type.Object({
|
|
384
|
+
folder_path: Type.String({ description: "Folder path, relative to the project root or absolute." }),
|
|
385
|
+
recursive: Type.Optional(Type.Boolean({ description: "Whether to scan subfolders.", default: false })),
|
|
386
|
+
max_side: Type.Optional(Type.Integer({ description: "Maximum resized image side before submission.", default: 4000 })),
|
|
387
|
+
jpeg_quality: Type.Optional(Type.Integer({ description: "JPEG conversion quality from 1 to 100.", default: 96 })),
|
|
388
|
+
limit: Type.Optional(Type.Integer({ description: "Optional maximum number of images to process." })),
|
|
389
|
+
}),
|
|
390
|
+
async execute(_id, params, _signal, onUpdate, ctx) {
|
|
391
|
+
onUpdate?.({ content: [{ type: "text", text: "Starting Vision Inventory MCP server and processing folder..." }], details: {} });
|
|
392
|
+
return callVisionTool(ctx, "process_image_folder", params as Record<string, unknown>);
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
pi.registerTool({
|
|
397
|
+
name: "vision_inventory_save",
|
|
398
|
+
label: "Vision Inventory: Save",
|
|
399
|
+
description: "Save Vision Inventory results to JSON or CSV using the local MCP server.",
|
|
400
|
+
promptSnippet: "Save Vision Inventory output as JSON or CSV.",
|
|
401
|
+
promptGuidelines: [
|
|
402
|
+
"Use vision_inventory_save to save results returned by vision_inventory_process_image or vision_inventory_process_folder.",
|
|
403
|
+
],
|
|
404
|
+
parameters: Type.Object({
|
|
405
|
+
inventory: Type.Any({ description: "Inventory object returned by a Vision Inventory tool." }),
|
|
406
|
+
output_path: Type.String({ description: "Output file path, relative to the project root or absolute." }),
|
|
407
|
+
format: Type.Optional(StringEnum(["json", "csv"] as const, { description: "Output format.", default: "json" })),
|
|
408
|
+
}),
|
|
409
|
+
async execute(_id, params, _signal, onUpdate, ctx) {
|
|
410
|
+
onUpdate?.({ content: [{ type: "text", text: "Saving Vision Inventory output..." }], details: {} });
|
|
411
|
+
return callVisionTool(ctx, "save_inventory", params as Record<string, unknown>);
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
pi.registerCommand("vision-inventory-restart", {
|
|
416
|
+
description: "Restart the Vision Inventory MCP bridge process",
|
|
417
|
+
handler: async (_args, ctx) => {
|
|
418
|
+
await client?.stop();
|
|
419
|
+
client = undefined;
|
|
420
|
+
ctx.ui.notify("Vision Inventory MCP bridge restarted", "info");
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
async function runSetup(ctx: { ui: any; hasUI: boolean }, forceCredentials = false): Promise<void> {
|
|
425
|
+
const existing = await loadCredentials();
|
|
426
|
+
const hasEffectiveAccountId = Boolean(process.env.CLOUDFLARE_ACCOUNT_ID || existing.cloudflareAccountId);
|
|
427
|
+
const hasEffectiveToken = Boolean(process.env.CLOUDFLARE_AUTH_TOKEN || process.env.CLOUDFLARE_API_TOKEN || existing.cloudflareAuthToken);
|
|
428
|
+
if ((!hasEffectiveAccountId || !hasEffectiveToken || forceCredentials) && ctx.hasUI) {
|
|
429
|
+
ctx.ui.notify("Vision Inventory stores Cloudflare credentials in ~/.pi/agent/vision-inventory/credentials.json with chmod 600 when supported. Use /vision-inventory-credentials to change them.", "info");
|
|
430
|
+
const cloudflareAccountId = await ctx.ui.input("Cloudflare account ID", existing.cloudflareAccountId || "");
|
|
431
|
+
const cloudflareAuthToken = await ctx.ui.input("Cloudflare Workers AI token", existing.cloudflareAuthToken ? "<keep existing; paste new token to replace>" : "");
|
|
432
|
+
const workersAiModel = await ctx.ui.input("Workers AI model", existing.workersAiModel || "@cf/meta/llama-4-scout-17b-16e-instruct");
|
|
433
|
+
await saveCredentials({
|
|
434
|
+
cloudflareAccountId: cloudflareAccountId || existing.cloudflareAccountId,
|
|
435
|
+
cloudflareAuthToken: cloudflareAuthToken && !cloudflareAuthToken.startsWith("<keep existing") ? cloudflareAuthToken : existing.cloudflareAuthToken,
|
|
436
|
+
workersAiModel: workersAiModel || existing.workersAiModel || "@cf/meta/llama-4-scout-17b-16e-instruct",
|
|
437
|
+
});
|
|
438
|
+
} else if ((!hasEffectiveAccountId || !hasEffectiveToken) && !ctx.hasUI) {
|
|
439
|
+
ctx.ui.notify("Missing Cloudflare credentials. Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_AUTH_TOKEN/CLOUDFLARE_API_TOKEN, or run /vision-inventory-setup in interactive Pi.", "error");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const deps = await pi.exec(PYTHON_COMMAND, ["-c", "import mcp, requests, PIL, dotenv; print('ok')"], { timeout: 10_000 });
|
|
443
|
+
if (deps.code !== 0) {
|
|
444
|
+
if (!ctx.hasUI) {
|
|
445
|
+
ctx.ui.notify(`Missing Python dependencies. Run: ${PYTHON_COMMAND} -m pip install -r ${join(packageRoot, "requirements.txt")}`, "error");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const install = await ctx.ui.confirm("Install Python dependencies?", `${PYTHON_COMMAND} -m pip install -r ${join(packageRoot, "requirements.txt")}`);
|
|
449
|
+
if (install) {
|
|
450
|
+
const result = await pi.exec(PYTHON_COMMAND, ["-m", "pip", "install", "-r", join(packageRoot, "requirements.txt")], { timeout: 120_000 });
|
|
451
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || "pip install failed");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const commands = pi.getCommands().map((command) => command.name.toLowerCase());
|
|
456
|
+
const tools = pi.getAllTools().map((tool) => tool.name.toLowerCase());
|
|
457
|
+
const hasWebDependency = [...commands, ...tools].some((name) => name.includes("search") || name.includes("brave") || name.includes("browser") || name.includes("web"));
|
|
458
|
+
if (!hasWebDependency) {
|
|
459
|
+
ctx.ui.notify("Agent datasheet enrichment requires a web-search/browser Pi tool or skill. This package intentionally does not bundle one; install/enable your preferred search dependency before using /vision-inventory-agent-bom.", "error");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
ctx.ui.notify(`Vision Inventory setup checked. Package root: ${packageRoot}`, "info");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
pi.registerCommand("vision-inventory-setup", {
|
|
466
|
+
description: "Configure Vision Inventory credentials and check Python/web-search dependencies",
|
|
467
|
+
handler: async (args, ctx) => {
|
|
468
|
+
try {
|
|
469
|
+
await runSetup(ctx, args.includes("--reset") || args.includes("--credentials"));
|
|
470
|
+
} catch (error) {
|
|
471
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
pi.registerCommand("vision-inventory-credentials", {
|
|
477
|
+
description: "Change stored Cloudflare credentials for Vision Inventory",
|
|
478
|
+
handler: async (_args, ctx) => {
|
|
479
|
+
try {
|
|
480
|
+
await runSetup(ctx, true);
|
|
481
|
+
await client?.stop();
|
|
482
|
+
client = undefined;
|
|
483
|
+
} catch (error) {
|
|
484
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
pi.registerCommand("vision-inventory-bom", {
|
|
490
|
+
description: "Run the folder-to-CSV Vision Inventory BOM workflow",
|
|
491
|
+
handler: async (args, ctx) => {
|
|
492
|
+
ctx.ui.setStatus("vision-inventory", "Running BOM workflow...");
|
|
493
|
+
try {
|
|
494
|
+
const output = await runBatchWorkflow(packageRoot, ctx.cwd, args || "");
|
|
495
|
+
ctx.ui.notify(output.slice(-4000), "info");
|
|
496
|
+
} catch (error) {
|
|
497
|
+
ctx.ui.notify(error instanceof Error ? error.message.slice(-4000) : String(error), "error");
|
|
498
|
+
} finally {
|
|
499
|
+
ctx.ui.setStatus("vision-inventory", "");
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
pi.registerCommand("vision-inventory-agent-bom", {
|
|
505
|
+
description: "Run the full Vision Inventory workflow with agent-assisted datasheet enrichment",
|
|
506
|
+
handler: async (args, ctx) => {
|
|
507
|
+
const parsed = splitCommandArgs(args || "");
|
|
508
|
+
if (parsed.length < 2) {
|
|
509
|
+
ctx.ui.notify("Usage: /vision-inventory-agent-bom <image_folder> <output_dir> [options]", "error");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
await runSetup(ctx, false);
|
|
514
|
+
const normalizedArgs = normalizeWorkflowArgs(ctx.cwd, parsed).map((arg) => JSON.stringify(arg)).join(" ");
|
|
515
|
+
const outputDir = normalizeWorkflowArgs(ctx.cwd, parsed)[1];
|
|
516
|
+
const prompt = `Run the complete Vision Electronic Indexing workflow as an agent.\n\nPackage root containing the bundled Python workflow: ${packageRoot}\nCommand arguments, already resolved relative to the user's cwd: ${normalizedArgs}\nOutput directory: ${outputDir}\n\nImportant external agent dependency: datasheet enrichment requires a web-search/browser Pi tool or skill. This package intentionally does not bundle a web-search dependency. If no search/browser tool is available, stop after generating parts_to_lookup.json and tell the user which dependency is missing.\n\nDo these steps end-to-end:\n1. Run: ${PYTHON_COMMAND} ${join(packageRoot, "scripts", "inventory_folder_to_csv.py")} ${normalizedArgs}\n2. Read ${outputDir}/parts_to_lookup.json.\n3. For every part, web-search for a datasheet. Prefer official manufacturer pages/PDFs.\n4. Write ${outputDir}/datasheet_cache.json using ${outputDir}/datasheet_cache.template.json as the exact shape.\n5. Rerun: ${PYTHON_COMMAND} ${join(packageRoot, "scripts", "inventory_folder_to_csv.py")} ${normalizedArgs} --skip-vision\n6. Read ${outputDir}/inventory.csv and ${outputDir}/inventory_evidence.csv.\n7. Summarize final BOM rows and call out every uncertainty.\n\nRules:\n- Do not invent datasheets, manufacturers, or descriptions.\n- Set verified=false if the part or datasheet match is uncertain.\n- Keep descriptions short, like: \"74ls (4 bit) adder low power schottky ttl 5v DIP\".\n- Preserve raw JSON and evidence files.\n- Do not expose Cloudflare credentials.\n- If a command fails because credentials or Python dependencies are missing, tell the user to run /vision-inventory-setup or /vision-inventory-credentials.`;
|
|
517
|
+
|
|
518
|
+
await ctx.sendUserMessage(prompt);
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
pi.on("session_shutdown", async () => {
|
|
523
|
+
await client?.stop();
|
|
524
|
+
client = undefined;
|
|
525
|
+
});
|
|
526
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Run the full Vision Inventory image-to-enriched-BOM workflow
|
|
3
|
+
argument-hint: "<image_folder> <output_dir> [options]"
|
|
4
|
+
---
|
|
5
|
+
Run the full Vision Electronic Indexing workflow for: $ARGUMENTS
|
|
6
|
+
|
|
7
|
+
Prefer the extension command when available:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
/vision-inventory-agent-bom $ARGUMENTS
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
External agent dependency: datasheet enrichment requires a web-search/browser Pi tool or skill. This package intentionally does not bundle one.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: vision-inventory-workflow
|
|
3
|
+
description: Run the Vision Electronic Indexing workflow for electronics/PCB photos: process images, create parts_to_lookup.json, verify datasheets with web search, fill datasheet_cache.json, regenerate CSV, and summarize uncertainties.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Vision Inventory Workflow
|
|
7
|
+
|
|
8
|
+
Use this skill when the user wants an agent to produce an enriched electronics BOM from a folder of electronics/PCB images.
|
|
9
|
+
|
|
10
|
+
## External Dependencies
|
|
11
|
+
|
|
12
|
+
This package intentionally does **not** bundle these dependencies:
|
|
13
|
+
|
|
14
|
+
- Python packages from `requirements.txt`: `mcp`, `requests`, `pillow`, `python-dotenv`; optional `pillow-heif` for HEIC/HEIF.
|
|
15
|
+
- A Pi web-search/browser tool or skill for datasheet enrichment.
|
|
16
|
+
- Cloudflare Workers AI credentials.
|
|
17
|
+
|
|
18
|
+
Use `/vision-inventory-setup` to configure credentials and check/install Python dependencies. Use `/vision-inventory-credentials` to change stored Cloudflare credentials.
|
|
19
|
+
|
|
20
|
+
## Preferred Command
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
/vision-inventory-agent-bom <image_folder> <output_dir> [options]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Options are forwarded to `scripts/inventory_folder_to_csv.py`, for example `--recursive`, `--limit 3`, `--max-side 4000`, and `--jpeg-quality 96`.
|
|
27
|
+
|
|
28
|
+
## Agent Rules
|
|
29
|
+
|
|
30
|
+
- Run the deterministic Python workflow first.
|
|
31
|
+
- Read `parts_to_lookup.json`.
|
|
32
|
+
- Verify each part against datasheets, preferring official manufacturer pages/PDFs.
|
|
33
|
+
- Fill `datasheet_cache.json` using `datasheet_cache.template.json` as the shape.
|
|
34
|
+
- Rerun the Python workflow with `--skip-vision`.
|
|
35
|
+
- Review `inventory.csv` and `inventory_evidence.csv`.
|
|
36
|
+
- Do not invent datasheets, manufacturers, voltages, package names, or descriptions.
|
|
37
|
+
- Set `verified=false` if uncertain and explain in `notes`.
|
|
38
|
+
- Preserve raw JSON and evidence files.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pichi-Cell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|