@rol1n/pi-kit 0.1.2 → 0.1.3
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 +131 -56
- package/README_zh.md +131 -56
- package/ask-u-questions/README.md +2 -2
- package/continue-signal/README.md +2 -2
- package/custom-providers/README.md +2 -2
- package/image-generate/README.md +4 -2
- package/memory-log/README.md +2 -2
- package/model-vision/README.md +2 -2
- package/model-vision/image.ts +84 -0
- package/model-vision/index.ts +124 -0
- package/model-vision/network.ts +82 -0
- package/model-vision/requests.ts +151 -0
- package/multi-harness/README.md +4 -4
- package/oai-subquota/README.md +2 -2
- package/package.json +18 -24
- package/prompt-snipaste/README.md +19 -5
- package/prompt-snipaste/index.ts +4 -4
- package/prompt-snipaste/snippets.ts +28 -149
- package/prompt-snipaste/types.ts +1 -0
- package/screenshot/README.md +2 -2
- package/screenshot/index.ts +126 -0
- package/scripts/check-package.mjs +54 -0
- package/skills/worktree-studio/SKILL.md +99 -0
- package/srp-theme/README.md +2 -2
- package/subagent/README.md +4 -4
- package/tui-asr/README.md +4 -2
- package/web-reach/README.md +2 -2
- package/web-reach/html.ts +83 -0
- package/web-reach/index.ts +82 -0
- package/web-reach/page.ts +214 -0
- package/web-reach/rsc.ts +161 -0
- package/web-reach/search.ts +85 -0
- package/web-reach/transport.ts +35 -0
- package/windows-wheel-fix/README.md +2 -2
- /package/{prompt-snipaste/prompts → prompts}/ask-questions.md +0 -0
- /package/{prompt-snipaste/prompts → prompts}/delegate-exploration.md +0 -0
- /package/{prompt-snipaste/prompts → prompts}/diagnose-report.md +0 -0
- /package/{prompt-snipaste/prompts → prompts}/frontend-tech-preset.md +0 -0
- /package/{prompt-snipaste/prompts → prompts}/orchestrator-mode.md +0 -0
- /package/{prompt-snipaste/prompts → prompts}/session-kickoff.md +0 -0
- /package/{prompt-snipaste/prompts → prompts}/verify-not-assume.md +0 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { extname, resolve } from "node:path";
|
|
3
|
+
import { combinedSignal, readBounded, validateRemoteUrl } from "./network.ts";
|
|
4
|
+
|
|
5
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
const MAX_REDIRECTS = 5;
|
|
7
|
+
|
|
8
|
+
const MIME_MAP: Record<string, string> = {
|
|
9
|
+
".png": "image/png",
|
|
10
|
+
".jpg": "image/jpeg",
|
|
11
|
+
".jpeg": "image/jpeg",
|
|
12
|
+
".webp": "image/webp",
|
|
13
|
+
".gif": "image/gif",
|
|
14
|
+
".bmp": "image/bmp",
|
|
15
|
+
};
|
|
16
|
+
const SUPPORTED_MIMES = new Set(Object.values(MIME_MAP));
|
|
17
|
+
|
|
18
|
+
function detectMime(buffer: Buffer): string | undefined {
|
|
19
|
+
if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
|
|
20
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
|
|
21
|
+
if (
|
|
22
|
+
buffer.subarray(0, 6).toString("ascii") === "GIF87a" ||
|
|
23
|
+
buffer.subarray(0, 6).toString("ascii") === "GIF89a"
|
|
24
|
+
) {
|
|
25
|
+
return "image/gif";
|
|
26
|
+
}
|
|
27
|
+
if (
|
|
28
|
+
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
29
|
+
buffer.subarray(8, 12).toString("ascii") === "WEBP"
|
|
30
|
+
) {
|
|
31
|
+
return "image/webp";
|
|
32
|
+
}
|
|
33
|
+
if (buffer.subarray(0, 2).toString("ascii") === "BM") return "image/bmp";
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function fetchRemoteImage(url: string, signal: AbortSignal): Promise<{ buffer: Buffer; mime: string }> {
|
|
38
|
+
let current = await validateRemoteUrl(url);
|
|
39
|
+
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
|
40
|
+
const response = await fetch(current, {
|
|
41
|
+
redirect: "manual",
|
|
42
|
+
headers: { Accept: "image/png,image/jpeg,image/webp,image/gif,image/bmp" },
|
|
43
|
+
signal,
|
|
44
|
+
});
|
|
45
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
46
|
+
const location = response.headers.get("location");
|
|
47
|
+
if (!location) throw new Error(`Image redirect missing Location header: HTTP ${response.status}`);
|
|
48
|
+
if (redirects === MAX_REDIRECTS) throw new Error("Too many image redirects.");
|
|
49
|
+
current = await validateRemoteUrl(new URL(location, current).href);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!response.ok) throw new Error(`Failed to fetch image: HTTP ${response.status} ${response.statusText}`);
|
|
53
|
+
|
|
54
|
+
const mime = (response.headers.get("content-type") ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
55
|
+
if (!SUPPORTED_MIMES.has(mime)) throw new Error(`Unsupported remote image MIME type: ${mime || "unknown"}`);
|
|
56
|
+
return { buffer: await readBounded(response, MAX_IMAGE_BYTES), mime };
|
|
57
|
+
}
|
|
58
|
+
throw new Error("Too many image redirects.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function readImage(image: string, cwd: string, signal?: AbortSignal): Promise<string> {
|
|
62
|
+
const trimmed = image.trim();
|
|
63
|
+
if (!trimmed) throw new Error("Image path or URL cannot be empty.");
|
|
64
|
+
|
|
65
|
+
let buffer: Buffer;
|
|
66
|
+
let mime: string;
|
|
67
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
68
|
+
({ buffer, mime } = await fetchRemoteImage(trimmed, combinedSignal(30_000, signal)));
|
|
69
|
+
} else {
|
|
70
|
+
const filePath = resolve(cwd, trimmed);
|
|
71
|
+
const expectedMime = MIME_MAP[extname(filePath).toLowerCase()];
|
|
72
|
+
if (!expectedMime) throw new Error(`Unsupported image file extension: ${extname(filePath) || "none"}`);
|
|
73
|
+
const fileStat = await stat(filePath);
|
|
74
|
+
if (fileStat.size > MAX_IMAGE_BYTES) throw new Error("Image file exceeds 10 MiB limit.");
|
|
75
|
+
buffer = await readFile(filePath);
|
|
76
|
+
mime = expectedMime;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const detected = detectMime(buffer);
|
|
80
|
+
if (!detected || detected !== mime) {
|
|
81
|
+
throw new Error(`Image content does not match MIME type (expected ${mime}, detected ${detected ?? "unknown"}).`);
|
|
82
|
+
}
|
|
83
|
+
return `data:${mime};base64,${buffer.toString("base64")}`;
|
|
84
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-vision — Image understanding extension for Pi.
|
|
3
|
+
*
|
|
4
|
+
* Registers the `model_vision` tool to analyze local or remote images
|
|
5
|
+
* using a vision model and return a textual description.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_MAX_BYTES,
|
|
10
|
+
DEFAULT_MAX_LINES,
|
|
11
|
+
truncateHead,
|
|
12
|
+
type ExtensionAPI,
|
|
13
|
+
type ExtensionContext,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
import { readImage } from "./image.ts";
|
|
18
|
+
import { callOpenAICompatible, callPiNative } from "./requests.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Configure your default vision model(s) here.
|
|
22
|
+
* The first available model in the chain will be used; subsequent ones act as fallbacks.
|
|
23
|
+
* Supported protocols: "pi-native" (Antigravity/built-in) or "openai-completions".
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_VISION_MODELS: Array<{
|
|
26
|
+
provider: string;
|
|
27
|
+
model: string;
|
|
28
|
+
protocol?: "pi-native" | "openai-completions";
|
|
29
|
+
}> = [
|
|
30
|
+
{ provider: "antigravity", model: "gemini-3.8-flash", protocol: "pi-native" },
|
|
31
|
+
{ provider: "shuaiapi", model: "gpt-6-luna", protocol: "openai-completions" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export type VisionCandidate = {
|
|
35
|
+
provider: string;
|
|
36
|
+
model: string;
|
|
37
|
+
protocol?: "pi-native" | "openai-completions";
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function getVisionCandidates(): VisionCandidate[] {
|
|
41
|
+
const models = DEFAULT_VISION_MODELS as VisionCandidate[] | VisionCandidate;
|
|
42
|
+
const list = Array.isArray(models) ? models : [models];
|
|
43
|
+
return list.map((c) => ({
|
|
44
|
+
...c,
|
|
45
|
+
protocol: c.protocol ?? (c.provider === "antigravity" ? "pi-native" : "openai-completions"),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_PROMPT =
|
|
50
|
+
"Describe this image in detail: transcribe any visible text verbatim, describe visual elements, layout, colors, and note any errors, anomalies, or important details.";
|
|
51
|
+
|
|
52
|
+
export async function analyzeImage(
|
|
53
|
+
image: string,
|
|
54
|
+
promptText: string | undefined,
|
|
55
|
+
cwd: string,
|
|
56
|
+
ctx: ExtensionContext,
|
|
57
|
+
signal?: AbortSignal,
|
|
58
|
+
): Promise<{ text: string; model: string; provider: string; truncated: boolean }> {
|
|
59
|
+
const candidates = getVisionCandidates();
|
|
60
|
+
if (!candidates.length) throw new Error("No vision models configured.");
|
|
61
|
+
|
|
62
|
+
const dataUri = await readImage(image, cwd, signal);
|
|
63
|
+
const prompt = promptText?.trim() || DEFAULT_PROMPT;
|
|
64
|
+
const errors: string[] = [];
|
|
65
|
+
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
try {
|
|
68
|
+
const text =
|
|
69
|
+
candidate.protocol === "pi-native"
|
|
70
|
+
? await callPiNative(ctx, candidate, dataUri, prompt, signal)
|
|
71
|
+
: await callOpenAICompatible(ctx, candidate, dataUri, prompt, signal);
|
|
72
|
+
const output = truncateHead(text, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
|
|
73
|
+
return {
|
|
74
|
+
text: output.content,
|
|
75
|
+
model: candidate.model,
|
|
76
|
+
provider: candidate.provider,
|
|
77
|
+
truncated: output.truncated,
|
|
78
|
+
};
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (signal?.aborted) throw error;
|
|
81
|
+
errors.push(`${candidate.provider}/${candidate.model}: ${error instanceof Error ? error.message : String(error)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error(`All vision models failed: ${errors.join("; ")}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function (pi: ExtensionAPI) {
|
|
89
|
+
pi.registerTool({
|
|
90
|
+
name: "model_vision",
|
|
91
|
+
label: "model_vision",
|
|
92
|
+
description:
|
|
93
|
+
"Analyze an image using a vision model and return a textual description. Supports local PNG/JPEG/WebP/GIF/BMP file paths or public http(s) URLs. Use prompt to specify OCR, error diagnosis, UI inspection, or detailed description.",
|
|
94
|
+
parameters: Type.Object({
|
|
95
|
+
image: Type.String({
|
|
96
|
+
minLength: 1,
|
|
97
|
+
description: "Local image path (relative to cwd or absolute) or public http(s) URL",
|
|
98
|
+
}),
|
|
99
|
+
prompt: Type.Optional(
|
|
100
|
+
Type.String({
|
|
101
|
+
description: "Specific question or instruction for the vision model",
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
}),
|
|
105
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
106
|
+
const result = await analyzeImage(params.image, params.prompt, ctx.cwd, ctx, signal);
|
|
107
|
+
return {
|
|
108
|
+
content: [{ type: "text", text: result.text }],
|
|
109
|
+
details: {
|
|
110
|
+
model: result.model,
|
|
111
|
+
provider: result.provider,
|
|
112
|
+
truncated: result.truncated,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
renderCall(args, theme, context) {
|
|
117
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
118
|
+
const { image } = args as { image?: string };
|
|
119
|
+
const display = image && image.length > 50 ? "..." + image.slice(-47) : image || "";
|
|
120
|
+
text.setText(theme.fg("toolTitle", theme.bold("model_vision ")) + theme.fg("accent", display));
|
|
121
|
+
return text;
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
|
|
4
|
+
export function combinedSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
|
|
5
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
6
|
+
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isPrivateIp(address: string): boolean {
|
|
10
|
+
if (net.isIPv4(address)) {
|
|
11
|
+
const [a, b] = address.split(".").map(Number);
|
|
12
|
+
return (
|
|
13
|
+
a === 0 ||
|
|
14
|
+
a === 10 ||
|
|
15
|
+
a === 127 ||
|
|
16
|
+
(a === 169 && b === 254) ||
|
|
17
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
18
|
+
(a === 192 && b === 168) ||
|
|
19
|
+
a >= 224
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
const norm = address.toLowerCase();
|
|
23
|
+
return (
|
|
24
|
+
norm === "::" ||
|
|
25
|
+
norm === "::1" ||
|
|
26
|
+
norm.startsWith("fc") ||
|
|
27
|
+
norm.startsWith("fd") ||
|
|
28
|
+
/^fe[89ab]/.test(norm) ||
|
|
29
|
+
norm.startsWith("::ffff:127.") ||
|
|
30
|
+
norm.startsWith("::ffff:10.") ||
|
|
31
|
+
norm.startsWith("::ffff:192.168.")
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function validateRemoteUrl(raw: string): Promise<URL> {
|
|
36
|
+
const url = new URL(raw);
|
|
37
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
38
|
+
throw new Error("Only http(s) URLs are supported for remote images.");
|
|
39
|
+
}
|
|
40
|
+
if (url.username || url.password) {
|
|
41
|
+
throw new Error("Image URL must not contain username or password.");
|
|
42
|
+
}
|
|
43
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
44
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
45
|
+
throw new Error(`Access to local address is blocked: ${hostname}`);
|
|
46
|
+
}
|
|
47
|
+
const addresses = net.isIP(hostname)
|
|
48
|
+
? [{ address: hostname }]
|
|
49
|
+
: await lookup(hostname, { all: true, verbatim: true });
|
|
50
|
+
if (!addresses.length || addresses.some(({ address }) => isPrivateIp(address))) {
|
|
51
|
+
throw new Error(`Access to private or local network address is blocked: ${hostname}`);
|
|
52
|
+
}
|
|
53
|
+
return url;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readBounded(response: Response, maxBytes: number): Promise<Buffer> {
|
|
57
|
+
const declared = Number(response.headers.get("content-length"));
|
|
58
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
59
|
+
throw new Error(`Content exceeds ${Math.round(maxBytes / (1024 * 1024))} MiB limit.`);
|
|
60
|
+
}
|
|
61
|
+
if (!response.body) return Buffer.alloc(0);
|
|
62
|
+
|
|
63
|
+
const reader = response.body.getReader();
|
|
64
|
+
const chunks: Uint8Array[] = [];
|
|
65
|
+
let total = 0;
|
|
66
|
+
try {
|
|
67
|
+
while (true) {
|
|
68
|
+
const { done, value } = await reader.read();
|
|
69
|
+
if (done) break;
|
|
70
|
+
if (!value) continue;
|
|
71
|
+
total += value.byteLength;
|
|
72
|
+
if (total > maxBytes) {
|
|
73
|
+
await reader.cancel();
|
|
74
|
+
throw new Error(`Content exceeds ${Math.round(maxBytes / (1024 * 1024))} MiB limit.`);
|
|
75
|
+
}
|
|
76
|
+
chunks.push(value);
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
reader.releaseLock();
|
|
80
|
+
}
|
|
81
|
+
return Buffer.concat(chunks, total);
|
|
82
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { VisionCandidate } from "./index.ts";
|
|
3
|
+
import { combinedSignal, readBounded } from "./network.ts";
|
|
4
|
+
|
|
5
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
6
|
+
const MAX_TOKENS = 8192;
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 90_000;
|
|
8
|
+
|
|
9
|
+
async function parseChatResponse(response: Response): Promise<string> {
|
|
10
|
+
const raw = (await readBounded(response, MAX_RESPONSE_BYTES)).toString("utf8");
|
|
11
|
+
if (!(response.headers.get("content-type") ?? "").includes("text/event-stream")) {
|
|
12
|
+
let json: any;
|
|
13
|
+
try {
|
|
14
|
+
json = JSON.parse(raw);
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error(`Vision model returned invalid JSON: ${raw.slice(0, 200)}`);
|
|
17
|
+
}
|
|
18
|
+
const content = json?.choices?.[0]?.message?.content;
|
|
19
|
+
if (typeof content === "string") return content.trim();
|
|
20
|
+
if (Array.isArray(content)) {
|
|
21
|
+
return content
|
|
22
|
+
.map((part) => (typeof part === "string" ? part : part?.text ?? ""))
|
|
23
|
+
.join("")
|
|
24
|
+
.trim();
|
|
25
|
+
}
|
|
26
|
+
return String(json?.choices?.[0]?.message?.reasoning_content ?? "").trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let text = "";
|
|
30
|
+
for (const line of raw.split("\n")) {
|
|
31
|
+
if (!line.startsWith("data:")) continue;
|
|
32
|
+
const payload = line.slice(5).trim();
|
|
33
|
+
if (!payload || payload === "[DONE]") continue;
|
|
34
|
+
try {
|
|
35
|
+
const delta = JSON.parse(payload)?.choices?.[0]?.delta;
|
|
36
|
+
const content = delta?.content ?? delta?.reasoning_content;
|
|
37
|
+
if (typeof content === "string") text += content;
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
return text.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function callOpenAICompatible(
|
|
44
|
+
ctx: ExtensionContext,
|
|
45
|
+
candidate: VisionCandidate,
|
|
46
|
+
dataUri: string,
|
|
47
|
+
prompt: string,
|
|
48
|
+
signal?: AbortSignal,
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
const registered = ctx.modelRegistry.getProvider(candidate.provider);
|
|
51
|
+
const resolved = await ctx.modelRegistry.getProviderAuth(candidate.provider);
|
|
52
|
+
const apiKey = resolved?.auth.apiKey;
|
|
53
|
+
if (!apiKey) {
|
|
54
|
+
throw new Error(`No API key found for provider "${candidate.provider}". Run /login ${candidate.provider} first.`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const baseUrl = (resolved.auth.baseUrl || registered?.baseUrl)?.replace(/\/+$/, "");
|
|
58
|
+
if (!baseUrl) {
|
|
59
|
+
throw new Error(`Provider "${candidate.provider}" has no base URL configured.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const headers: Record<string, string> = {
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
Authorization: `Bearer ${apiKey}`,
|
|
65
|
+
};
|
|
66
|
+
for (const source of [registered?.headers, resolved.auth.headers]) {
|
|
67
|
+
for (const [key, value] of Object.entries(source ?? {})) {
|
|
68
|
+
if (typeof value === "string") headers[key] = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers,
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
model: candidate.model,
|
|
77
|
+
messages: [
|
|
78
|
+
{
|
|
79
|
+
role: "user",
|
|
80
|
+
content: [
|
|
81
|
+
{ type: "text", text: prompt },
|
|
82
|
+
{ type: "image_url", image_url: { url: dataUri } },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
max_tokens: MAX_TOKENS,
|
|
87
|
+
}),
|
|
88
|
+
signal: combinedSignal(REQUEST_TIMEOUT_MS, signal),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const errorBody = (await readBounded(response, 16 * 1024)).toString("utf8");
|
|
93
|
+
throw new Error(`HTTP ${response.status}: ${errorBody.slice(0, 300)}`);
|
|
94
|
+
}
|
|
95
|
+
const text = await parseChatResponse(response);
|
|
96
|
+
if (!text) throw new Error("Vision model returned an empty response.");
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function callPiNative(
|
|
101
|
+
ctx: ExtensionContext,
|
|
102
|
+
candidate: VisionCandidate,
|
|
103
|
+
dataUri: string,
|
|
104
|
+
prompt: string,
|
|
105
|
+
signal?: AbortSignal,
|
|
106
|
+
): Promise<string> {
|
|
107
|
+
const model = ctx.modelRegistry.find(candidate.provider, candidate.model);
|
|
108
|
+
if (!model) {
|
|
109
|
+
throw new Error(`Model ${candidate.provider}/${candidate.model} not found in modelRegistry.`);
|
|
110
|
+
}
|
|
111
|
+
const auth = await ctx.modelRegistry.getProviderAuth(candidate.provider);
|
|
112
|
+
const match = dataUri.match(/^data:([^;]+);base64,(.+)$/);
|
|
113
|
+
if (!match) throw new Error("Failed to parse image data URI.");
|
|
114
|
+
const [, mimeType, base64Data] = match;
|
|
115
|
+
|
|
116
|
+
const { completeSimple } = await import("@earendil-works/pi-ai/compat");
|
|
117
|
+
const response = await completeSimple(
|
|
118
|
+
model,
|
|
119
|
+
{
|
|
120
|
+
messages: [
|
|
121
|
+
{
|
|
122
|
+
role: "user",
|
|
123
|
+
content: [
|
|
124
|
+
{ type: "text", text: prompt },
|
|
125
|
+
{ type: "image", data: base64Data, mimeType },
|
|
126
|
+
],
|
|
127
|
+
timestamp: Date.now(),
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
apiKey: auth?.auth.apiKey,
|
|
133
|
+
headers: auth?.auth.headers,
|
|
134
|
+
signal: combinedSignal(REQUEST_TIMEOUT_MS, signal),
|
|
135
|
+
maxTokens: MAX_TOKENS,
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (response.stopReason === "error") {
|
|
140
|
+
throw new Error(response.errorMessage || "Vision model request failed.");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const text = response.content
|
|
144
|
+
?.filter((c: any) => c.type === "text")
|
|
145
|
+
?.map((c: any) => c.text)
|
|
146
|
+
?.join("\n")
|
|
147
|
+
?.trim();
|
|
148
|
+
|
|
149
|
+
if (!text) throw new Error("Vision model returned an empty response.");
|
|
150
|
+
return text;
|
|
151
|
+
}
|
package/multi-harness/README.md
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
A small delegation runtime with one tool, `multi_harness`. `pi`, `codex`, `claude`, and `opencode` are equal CLI peers. The original `subagent` extension remains a separate, lean Pi-only tool.
|
|
4
4
|
|
|
5
|
-
The runtime accepts **one named worker and an existing cwd**. Those decisions belong to the caller.
|
|
5
|
+
The runtime accepts **one named worker and an existing cwd**. Those decisions belong to the caller. The bundled [worktree-studio skill](../skills/worktree-studio/SKILL.md) can coordinate isolated implementations and their acceptance review; enable it through the kit's `skills` resource selection.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
pi install npm:@rol1n/
|
|
10
|
+
pi install npm:@rol1n/pi-kit
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Included and loaded by default in `@rol1n/pi-kit`. See [resource selection](../README.md#resource-selection) to disable this entry or load only selected extensions. Enable one copy of the extension.
|
|
14
14
|
|
|
15
15
|
## Use
|
|
16
16
|
|
|
17
|
-
Load `multi-harness` through
|
|
17
|
+
Load `multi-harness` through `pi-kit`, or run from this repository:
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
20
|
pi --no-extensions -e ./multi-harness/index.ts
|
package/oai-subquota/README.md
CHANGED
|
@@ -5,10 +5,10 @@ Monitor OpenAI Codex subscription quota (5-hour and 7-day windows, resets, and c
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
pi install npm:@rol1n/
|
|
8
|
+
pi install npm:@rol1n/pi-kit
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Included and loaded by default in `@rol1n/pi-kit`. See [resource selection](../README.md#resource-selection) to disable this entry or load only selected extensions.
|
|
12
12
|
|
|
13
13
|
## Commands
|
|
14
14
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rol1n/pi-kit",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "A selectable collection of Pi extensions
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "A selectable collection of Pi extensions, prompt templates, and skills.",
|
|
5
5
|
"author": "RoL1n_SrP",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -20,43 +20,32 @@
|
|
|
20
20
|
"node": ">=22.19.0"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"publish:ci": "node scripts/package.mjs publish",
|
|
27
|
-
"prepack": "node scripts/package.mjs check"
|
|
23
|
+
"check": "node scripts/check-package.mjs",
|
|
24
|
+
"test": "node --experimental-strip-types --test memory-log/hooks/*.test.ts memory-log/ledger/*.test.ts prompt-snipaste/*.test.ts",
|
|
25
|
+
"prepack": "npm run check"
|
|
28
26
|
},
|
|
29
27
|
"publishConfig": {
|
|
30
28
|
"access": "public"
|
|
31
29
|
},
|
|
32
30
|
"files": [
|
|
33
31
|
"README_zh.md",
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"image-generate/README.md",
|
|
38
|
-
"memory-log/README.md",
|
|
39
|
-
"model-vision/README.md",
|
|
40
|
-
"multi-harness/README.md",
|
|
41
|
-
"oai-subquota/README.md",
|
|
42
|
-
"prompt-snipaste/README.md",
|
|
43
|
-
"screenshot/README.md",
|
|
44
|
-
"srp-theme/README.md",
|
|
45
|
-
"subagent/README.md",
|
|
46
|
-
"tui-asr/README.md",
|
|
47
|
-
"web-reach/README.md",
|
|
48
|
-
"windows-wheel-fix/README.md",
|
|
32
|
+
"scripts/check-package.mjs",
|
|
33
|
+
"prompts",
|
|
34
|
+
"skills",
|
|
49
35
|
"ask-u-questions",
|
|
50
36
|
"continue-signal",
|
|
51
37
|
"custom-providers",
|
|
52
38
|
"image-generate",
|
|
53
39
|
"memory-log",
|
|
40
|
+
"model-vision",
|
|
54
41
|
"multi-harness",
|
|
55
42
|
"subagent",
|
|
56
43
|
"oai-subquota",
|
|
57
44
|
"prompt-snipaste",
|
|
45
|
+
"screenshot",
|
|
58
46
|
"srp-theme",
|
|
59
47
|
"tui-asr",
|
|
48
|
+
"web-reach",
|
|
60
49
|
"windows-wheel-fix",
|
|
61
50
|
"!**/test/**",
|
|
62
51
|
"!**/tests/**",
|
|
@@ -78,17 +67,22 @@
|
|
|
78
67
|
"./custom-providers/index.ts",
|
|
79
68
|
"./image-generate/index.ts",
|
|
80
69
|
"./memory-log/index.ts",
|
|
70
|
+
"./model-vision/index.ts",
|
|
81
71
|
"./multi-harness/index.ts",
|
|
82
72
|
"./oai-subquota/index.ts",
|
|
83
73
|
"./prompt-snipaste/index.ts",
|
|
74
|
+
"./screenshot/index.ts",
|
|
84
75
|
"./srp-theme/index.ts",
|
|
85
76
|
"./subagent/index.ts",
|
|
86
77
|
"./tui-asr/index.ts",
|
|
78
|
+
"./web-reach/index.ts",
|
|
87
79
|
"./windows-wheel-fix/index.ts"
|
|
88
80
|
],
|
|
89
|
-
"skills": [
|
|
81
|
+
"skills": [
|
|
82
|
+
"./skills"
|
|
83
|
+
],
|
|
90
84
|
"prompts": [
|
|
91
|
-
"./
|
|
85
|
+
"./prompts"
|
|
92
86
|
],
|
|
93
87
|
"themes": []
|
|
94
88
|
}
|
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
# prompt-snipaste
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A generic composer for Pi prompt templates: select snippets to prepend or append to the next user message, with live preview and a session transcript card. It ships no private template library and requires no particular prompt names.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
pi install npm:@rol1n/
|
|
8
|
+
pi install npm:@rol1n/pi-kit
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Included and loaded by default in `@rol1n/pi-kit`. See [resource selection](../README.md#resource-selection) to disable this entry or load only selected extensions.
|
|
12
12
|
|
|
13
13
|
## Use
|
|
14
14
|
|
|
15
|
-
Open with `/snipaste` or `Alt+S`. Navigate with arrow keys, toggle with `Space`, preview with `Tab`, confirm with `Enter`, and cancel with `Escape`.
|
|
15
|
+
Open with `/snipaste` or `Alt+S`. Navigate with arrow keys, toggle with `Space`, preview with `Tab`, confirm with `Enter`, and cancel with `Escape`. A confirmed selection applies once, to the next user message.
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## Prompt sources and filtering
|
|
18
|
+
|
|
19
|
+
Pi owns discovery and filtering. The selector reads only prompt commands exposed by `pi.getCommands()`, including templates from personal configuration, trusted projects, and other packages. It does not scan package directories, read settings itself, or fall back to disabled templates.
|
|
20
|
+
|
|
21
|
+
- Ordinary Markdown and standard Pi templates work without any snippet-specific metadata.
|
|
22
|
+
- Add or change prompts through Pi's normal prompt directories or settings, then run `/reload`.
|
|
23
|
+
- Package `prompts` filters are respected. With no enabled templates, the menu displays a notice and injects nothing.
|
|
24
|
+
- Same-named files from different sources have independent identities based on their full source paths.
|
|
25
|
+
- The kit's root [`prompts/`](../prompts/) directory is a separate resource. Disabling it does not disable the selector; disabling the selector does not disable native template commands.
|
|
26
|
+
|
|
27
|
+
The selector composes template bodies as text; it does **not** invoke slash commands or expand positional argument placeholders such as `$1`. Use Pi's normal `/template args` invocation when argument expansion is needed.
|
|
28
|
+
|
|
29
|
+
## Optional frontmatter
|
|
18
30
|
|
|
19
31
|
```markdown
|
|
20
32
|
---
|
|
@@ -25,3 +37,5 @@ order: 100
|
|
|
25
37
|
---
|
|
26
38
|
Check correctness, error handling, and missing validation.
|
|
27
39
|
```
|
|
40
|
+
|
|
41
|
+
All fields are optional. `name` overrides the menu label, `description` describes the template, `placement` defaults to `append`, and `order` defaults to `100`. Prepend snippets come first, followed by append snippets; each group is sorted by numeric order and name.
|
package/prompt-snipaste/index.ts
CHANGED
|
@@ -20,11 +20,11 @@ export default function promptSnipaste(pi: ExtensionAPI) {
|
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
snippets = loadSnippets(
|
|
23
|
+
snippets = loadSnippets(pi);
|
|
24
24
|
enabled = new Set([...enabled].filter((id) => snippets.some((s) => s.id === id)));
|
|
25
25
|
|
|
26
26
|
if (snippets.length === 0) {
|
|
27
|
-
ctx.ui.notify("No
|
|
27
|
+
ctx.ui.notify("No enabled prompt templates. Add prompts in Pi settings, then /reload.", "warning");
|
|
28
28
|
updateWidget(ctx);
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
@@ -50,14 +50,14 @@ export default function promptSnipaste(pi: ExtensionAPI) {
|
|
|
50
50
|
pi.on("session_start", (_event, ctx) => {
|
|
51
51
|
enabled = new Set();
|
|
52
52
|
pendingTurnSnippets = null;
|
|
53
|
-
snippets = loadSnippets(
|
|
53
|
+
snippets = loadSnippets(pi);
|
|
54
54
|
updateWidget(ctx);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
pi.on("input", async (_event, ctx) => {
|
|
58
58
|
if (enabled.size === 0) return undefined;
|
|
59
59
|
|
|
60
|
-
snippets = loadSnippets(
|
|
60
|
+
snippets = loadSnippets(pi);
|
|
61
61
|
const active = snippets.filter((s) => enabled.has(s.id));
|
|
62
62
|
enabled = new Set();
|
|
63
63
|
updateWidget(ctx);
|