@qearlyao/familiar 0.2.4 → 0.2.5
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/dist/browser-tools.js +73 -20
- package/dist/image-gen.js +90 -10
- package/dist/util/fs.js +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/browser-tools.js
CHANGED
|
@@ -192,6 +192,15 @@ function parseJson(text) {
|
|
|
192
192
|
return undefined;
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
|
+
function parseOpenCliJsonOutput(result, context) {
|
|
196
|
+
if (result.json !== undefined)
|
|
197
|
+
return result.json;
|
|
198
|
+
const output = result.stdout.trim();
|
|
199
|
+
if (!output)
|
|
200
|
+
throw new Error(`OpenCLI ${context} returned no JSON output.`);
|
|
201
|
+
const tail = output.slice(-120).replace(/\s+/g, " ").trim();
|
|
202
|
+
throw new Error(`OpenCLI ${context} returned malformed JSON output near: ${tail || "(empty)"}`);
|
|
203
|
+
}
|
|
195
204
|
function stringArg(value) {
|
|
196
205
|
if (value === undefined || value === null)
|
|
197
206
|
return undefined;
|
|
@@ -522,24 +531,47 @@ function assertSiteAllowed(config, site) {
|
|
|
522
531
|
if (!config.browser.allowedSites[site])
|
|
523
532
|
throw new Error(`OpenCLI site is not allowlisted: ${site}`);
|
|
524
533
|
}
|
|
534
|
+
function commandInfoFromJson(json) {
|
|
535
|
+
if (!isRecord(json))
|
|
536
|
+
return undefined;
|
|
537
|
+
const name = stringArg(json.name);
|
|
538
|
+
if (!name)
|
|
539
|
+
return undefined;
|
|
540
|
+
return {
|
|
541
|
+
name,
|
|
542
|
+
access: stringArg(json.access) ?? "unknown",
|
|
543
|
+
description: stringArg(json.description),
|
|
544
|
+
usage: stringArg(json.usage),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
525
547
|
function parseSiteCommands(json) {
|
|
526
548
|
const commands = isRecord(json) && Array.isArray(json.commands) ? json.commands : [];
|
|
527
|
-
return
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
549
|
+
return {
|
|
550
|
+
commands: commands.flatMap((command) => {
|
|
551
|
+
const info = commandInfoFromJson(command);
|
|
552
|
+
return info ? [info] : [];
|
|
553
|
+
}),
|
|
554
|
+
complete: true,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
function parsePlainSiteHelp(site, text) {
|
|
558
|
+
const commandSection = text.match(/Commands:\n(?<body>[\s\S]*?)(?:\n\n[A-Z][^\n]* options:|\n\nCommon options:|\n\nBrowser common options:|\n\nAgent tip:|$)/);
|
|
559
|
+
const body = commandSection?.groups?.body;
|
|
560
|
+
if (!body)
|
|
561
|
+
return undefined;
|
|
562
|
+
const commands = body.split("\n").flatMap((line) => {
|
|
563
|
+
const match = line.match(/^\s{2}(?<name>[A-Za-z0-9._-]+)\b.*\[(?<access>read|write|admin)\]/);
|
|
564
|
+
if (!match?.groups)
|
|
532
565
|
return [];
|
|
533
|
-
const access = stringArg(command.access) ?? "unknown";
|
|
534
566
|
return [
|
|
535
567
|
{
|
|
536
|
-
name,
|
|
537
|
-
access,
|
|
538
|
-
|
|
539
|
-
usage: stringArg(command.usage),
|
|
568
|
+
name: match.groups.name,
|
|
569
|
+
access: match.groups.access,
|
|
570
|
+
usage: `opencli ${site} ${match.groups.name}`,
|
|
540
571
|
},
|
|
541
572
|
];
|
|
542
573
|
});
|
|
574
|
+
return { commands, complete: false };
|
|
543
575
|
}
|
|
544
576
|
async function loadSiteCommands(site, config, runner, signal) {
|
|
545
577
|
const result = await runner(openCliSpec(config, [...baseArgs(config), site, "--help", "-f", "json"]), {
|
|
@@ -548,13 +580,33 @@ async function loadSiteCommands(site, config, runner, signal) {
|
|
|
548
580
|
});
|
|
549
581
|
if (!result.ok)
|
|
550
582
|
throw new Error(formatBrowserResult(result, config.browser.maxOutputChars).text);
|
|
551
|
-
|
|
583
|
+
try {
|
|
584
|
+
return parseSiteCommands(parseOpenCliJsonOutput(result, `${site} metadata`));
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
const plainResult = await runner(openCliSpec(config, [...baseArgs(config), site, "--help"]), {
|
|
588
|
+
timeoutMs: config.browser.timeoutMs,
|
|
589
|
+
signal,
|
|
590
|
+
});
|
|
591
|
+
if (!plainResult.ok)
|
|
592
|
+
throw error;
|
|
593
|
+
const plainListing = parsePlainSiteHelp(site, plainResult.stdout);
|
|
594
|
+
if (!plainListing)
|
|
595
|
+
throw error;
|
|
596
|
+
return plainListing;
|
|
597
|
+
}
|
|
552
598
|
}
|
|
553
|
-
function
|
|
554
|
-
const
|
|
555
|
-
|
|
599
|
+
async function loadSiteCommand(site, command, config, runner, signal) {
|
|
600
|
+
const result = await runner(openCliSpec(config, [...baseArgs(config), site, command, "--help", "-f", "json"]), {
|
|
601
|
+
timeoutMs: config.browser.timeoutMs,
|
|
602
|
+
signal,
|
|
603
|
+
});
|
|
604
|
+
if (!result.ok)
|
|
605
|
+
throw new Error(formatBrowserResult(result, config.browser.maxOutputChars).text);
|
|
606
|
+
const info = commandInfoFromJson(parseOpenCliJsonOutput(result, `${site} ${command} metadata`));
|
|
607
|
+
if (!info || info.name !== command)
|
|
556
608
|
throw new Error(`OpenCLI site command is not available: ${site} ${command}`);
|
|
557
|
-
return
|
|
609
|
+
return info;
|
|
558
610
|
}
|
|
559
611
|
function buildSiteArgs(input, config, commandInfo) {
|
|
560
612
|
const site = stringArg(input.site);
|
|
@@ -603,8 +655,8 @@ async function buildSiteRunSpec(input, config, runner, signal) {
|
|
|
603
655
|
assertSafeName(site, "browser.site");
|
|
604
656
|
assertSafeName(command, "browser.command");
|
|
605
657
|
assertSiteAllowed(config, site);
|
|
606
|
-
const
|
|
607
|
-
return openCliSpec(config, buildSiteArgs(input, config,
|
|
658
|
+
const commandInfo = await loadSiteCommand(site, command, config, runner, signal);
|
|
659
|
+
return openCliSpec(config, buildSiteArgs(input, config, commandInfo));
|
|
608
660
|
}
|
|
609
661
|
function buildRunSpec(input, config) {
|
|
610
662
|
const backend = pageBackend(input, config);
|
|
@@ -622,15 +674,16 @@ async function listCommands(input, config, runner, signal) {
|
|
|
622
674
|
const sites = site ? [site] : Object.keys(config.browser.allowedSites);
|
|
623
675
|
const lines = ["allowlisted site commands:"];
|
|
624
676
|
for (const name of sites) {
|
|
625
|
-
const
|
|
677
|
+
const listing = await loadSiteCommands(name, config, runner, signal);
|
|
626
678
|
const groups = new Map();
|
|
627
|
-
for (const command of commands) {
|
|
679
|
+
for (const command of listing.commands) {
|
|
628
680
|
const names = groups.get(command.access) ?? [];
|
|
629
681
|
names.push(command.name);
|
|
630
682
|
groups.set(command.access, names);
|
|
631
683
|
}
|
|
632
684
|
const parts = Array.from(groups.entries()).map(([access, names]) => `${access}=[${names.join(", ")}]`);
|
|
633
|
-
|
|
685
|
+
const suffix = listing.complete ? "" : " (from plain help)";
|
|
686
|
+
lines.push(`- ${name}: ${parts.join(" ")}${suffix}`);
|
|
634
687
|
}
|
|
635
688
|
return lines.join("\n");
|
|
636
689
|
}
|
package/dist/image-gen.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { lstat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
3
4
|
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
4
5
|
import { findEnvKeys, generateImages, getEnvApiKey, getImageModels, getImageProviders, } from "@earendil-works/pi-ai";
|
|
5
6
|
import { Type } from "typebox";
|
|
@@ -13,9 +14,10 @@ const OPENROUTER_IMAGE_BASE_URL = "https://openrouter.ai/api/v1";
|
|
|
13
14
|
const imageGenSchema = Type.Object({
|
|
14
15
|
prompt: Type.String({ description: "Image generation prompt." }),
|
|
15
16
|
referenceImages: Type.Optional(Type.Array(Type.String(), {
|
|
16
|
-
description: "Optional. Image attachment IDs or names, or workspace-relative image file paths, to use as visual references. Prefer IDs from the attachment tags when available.",
|
|
17
|
+
description: "Optional. Image attachment IDs or names, or workspace-relative, absolute, or ~/ image file paths, to use as visual references. Prefer IDs from the attachment tags when available.",
|
|
17
18
|
})),
|
|
18
19
|
}, { additionalProperties: false });
|
|
20
|
+
const MAX_REMOTE_IMAGE_BYTES = 12 * 1024 * 1024;
|
|
19
21
|
function formatImageGenNotice(name) {
|
|
20
22
|
return `${IMAGE_GEN_NOTICE_PREFIX} ${name}`;
|
|
21
23
|
}
|
|
@@ -116,7 +118,7 @@ function recoveredImageFromBase64(value) {
|
|
|
116
118
|
data,
|
|
117
119
|
};
|
|
118
120
|
}
|
|
119
|
-
function
|
|
121
|
+
function recoveredInlineImageFromText(text) {
|
|
120
122
|
const trimmed = text.trim();
|
|
121
123
|
const dataUrlMatch = trimmed.match(/^data:(image\/[^;]+);base64,([A-Za-z0-9+/]+={0,2})$/);
|
|
122
124
|
if (dataUrlMatch)
|
|
@@ -127,7 +129,80 @@ function recoveredImageFromText(text) {
|
|
|
127
129
|
}
|
|
128
130
|
return recoveredImageFromBase64(trimmed);
|
|
129
131
|
}
|
|
130
|
-
function
|
|
132
|
+
function imageUrlFromMarkdownText(text) {
|
|
133
|
+
const match = text.match(/!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/i);
|
|
134
|
+
if (!match?.[1])
|
|
135
|
+
return undefined;
|
|
136
|
+
try {
|
|
137
|
+
const url = new URL(match[1]);
|
|
138
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
139
|
+
return undefined;
|
|
140
|
+
return url;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function readBoundedResponseBody(response, maxBytes) {
|
|
147
|
+
const reader = response.body?.getReader();
|
|
148
|
+
if (!reader) {
|
|
149
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
150
|
+
return buffer.byteLength > maxBytes ? undefined : buffer;
|
|
151
|
+
}
|
|
152
|
+
const chunks = [];
|
|
153
|
+
let total = 0;
|
|
154
|
+
try {
|
|
155
|
+
for (;;) {
|
|
156
|
+
const { done, value } = await reader.read();
|
|
157
|
+
if (done)
|
|
158
|
+
break;
|
|
159
|
+
const chunk = Buffer.from(value);
|
|
160
|
+
total += chunk.byteLength;
|
|
161
|
+
if (total > maxBytes)
|
|
162
|
+
return undefined;
|
|
163
|
+
chunks.push(chunk);
|
|
164
|
+
}
|
|
165
|
+
return Buffer.concat(chunks);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
reader.releaseLock();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function recoveredImageFromRemoteUrl(url, options) {
|
|
172
|
+
try {
|
|
173
|
+
const response = await fetch(url, { signal: options.signal });
|
|
174
|
+
if (!response.ok)
|
|
175
|
+
return undefined;
|
|
176
|
+
const contentLength = Number(response.headers.get("content-length") ?? 0);
|
|
177
|
+
if (contentLength > MAX_REMOTE_IMAGE_BYTES)
|
|
178
|
+
return undefined;
|
|
179
|
+
const bytes = await readBoundedResponseBody(response, MAX_REMOTE_IMAGE_BYTES);
|
|
180
|
+
if (!bytes)
|
|
181
|
+
return undefined;
|
|
182
|
+
const detectedMimeType = sniffImageMimeType(bytes);
|
|
183
|
+
if (!detectedMimeType)
|
|
184
|
+
return undefined;
|
|
185
|
+
return {
|
|
186
|
+
mimeType: detectedMimeType,
|
|
187
|
+
data: bytes.toString("base64"),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (options.signal?.aborted)
|
|
192
|
+
throw error;
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function recoveredImageFromText(text, options) {
|
|
197
|
+
const inlineImage = recoveredInlineImageFromText(text);
|
|
198
|
+
if (inlineImage)
|
|
199
|
+
return inlineImage;
|
|
200
|
+
const url = imageUrlFromMarkdownText(text);
|
|
201
|
+
if (!url)
|
|
202
|
+
return undefined;
|
|
203
|
+
return recoveredImageFromRemoteUrl(url, options);
|
|
204
|
+
}
|
|
205
|
+
async function normalizeCompatibleImageText(result, options) {
|
|
131
206
|
if (result.output.some((item) => item.type === "image"))
|
|
132
207
|
return result;
|
|
133
208
|
const output = [];
|
|
@@ -136,7 +211,7 @@ function normalizeCompatibleImageText(result) {
|
|
|
136
211
|
output.push(item);
|
|
137
212
|
continue;
|
|
138
213
|
}
|
|
139
|
-
const recovered = recoveredImageFromText(item.text);
|
|
214
|
+
const recovered = await recoveredImageFromText(item.text, options);
|
|
140
215
|
if (!recovered) {
|
|
141
216
|
output.push(item);
|
|
142
217
|
continue;
|
|
@@ -148,7 +223,11 @@ function normalizeCompatibleImageText(result) {
|
|
|
148
223
|
return { ...result, output };
|
|
149
224
|
}
|
|
150
225
|
function resolveWorkspaceReferencePath(config, rawRef) {
|
|
151
|
-
|
|
226
|
+
if (rawRef === "~" || rawRef.startsWith("~/"))
|
|
227
|
+
return resolve(homedir(), rawRef.slice(2));
|
|
228
|
+
if (isAbsolute(rawRef))
|
|
229
|
+
return resolve(rawRef);
|
|
230
|
+
const path = resolve(config.workspacePath, rawRef);
|
|
152
231
|
const workspaceRelative = relative(config.workspacePath, path);
|
|
153
232
|
if (!workspaceRelative || workspaceRelative.startsWith("..") || isAbsolute(workspaceRelative)) {
|
|
154
233
|
throw new Error(`Reference image path must be inside the workspace: ${rawRef}`);
|
|
@@ -304,13 +383,14 @@ async function writeGeneratedImages(config, mediaSink, result) {
|
|
|
304
383
|
async function tryGenerateImages(config, ref, prompt, references, workspaceRefs, signal, generate) {
|
|
305
384
|
const model = resolveImageModel(config, ref);
|
|
306
385
|
const context = await buildImageContext(model, prompt, references, workspaceRefs, config);
|
|
386
|
+
const result = await generate(model, context, {
|
|
387
|
+
apiKey: resolveImageModelApiKey(config, model),
|
|
388
|
+
signal,
|
|
389
|
+
timeoutMs: config.imageGen.timeoutMs,
|
|
390
|
+
});
|
|
307
391
|
return {
|
|
308
392
|
model,
|
|
309
|
-
result:
|
|
310
|
-
apiKey: resolveImageModelApiKey(config, model),
|
|
311
|
-
signal,
|
|
312
|
-
timeoutMs: config.imageGen.timeoutMs,
|
|
313
|
-
})),
|
|
393
|
+
result: await normalizeCompatibleImageText(result, { signal }),
|
|
314
394
|
};
|
|
315
395
|
}
|
|
316
396
|
function attemptDetails(model, result) {
|
package/dist/util/fs.js
CHANGED
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qearlyao/familiar",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@qearlyao/familiar",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.5",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@earendil-works/pi-agent-core": "0.75.5",
|