@leuria/cli 0.1.0 → 0.1.1
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 +1 -1
- package/dist/{chunk-BXSAYWR3.js → chunk-ES2YTJPL.js} +534 -30
- package/dist/cli.js +54 -7
- package/dist/index.d.ts +255 -122
- package/dist/index.js +1 -1
- package/package.json +7 -2
|
@@ -41,6 +41,14 @@ async function getRegistryEntry(id) {
|
|
|
41
41
|
}
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
|
+
function runnableHere(entry) {
|
|
45
|
+
const d = entry.distribution;
|
|
46
|
+
return Boolean(d.npx || d.uvx || d.binary?.[platformKey()]);
|
|
47
|
+
}
|
|
48
|
+
function clearRegistryCache() {
|
|
49
|
+
cache = null;
|
|
50
|
+
failedAt = 0;
|
|
51
|
+
}
|
|
44
52
|
|
|
45
53
|
// src/home.ts
|
|
46
54
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
@@ -454,9 +462,33 @@ async function ensureAgentOnce(id, options) {
|
|
|
454
462
|
}
|
|
455
463
|
if (installed?.version === entry.version) return installed;
|
|
456
464
|
options.onProgress?.(`Installing ${entry.name} ${entry.version} from the ACP registry\u2026`);
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
465
|
+
return install(entry);
|
|
466
|
+
}
|
|
467
|
+
function installedIds() {
|
|
468
|
+
const root = agentsDir();
|
|
469
|
+
if (!existsSync3(root)) return [];
|
|
470
|
+
const ids = readdirSync(root).filter((name) => name.includes("@") && existsSync3(join3(root, name, MANIFEST))).map((name) => name.slice(0, name.lastIndexOf("@")));
|
|
471
|
+
return [...new Set(ids)];
|
|
472
|
+
}
|
|
473
|
+
function pruneAgentVersions() {
|
|
474
|
+
const root = agentsDir();
|
|
475
|
+
for (const id of installedIds()) {
|
|
476
|
+
const versions = readdirSync(root).filter((name) => name.startsWith(`${id}@`) && existsSync3(join3(root, name, MANIFEST))).sort(compareVersions);
|
|
477
|
+
for (const old of versions.slice(0, -1)) rmSync(join3(root, old), { recursive: true, force: true });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async function updateAgents(onUpdated) {
|
|
481
|
+
clearRegistryCache();
|
|
482
|
+
for (const id of installedIds()) {
|
|
483
|
+
const installed = installedAgent(id);
|
|
484
|
+
const entry = await getRegistryEntry(id);
|
|
485
|
+
if (!installed || !entry || !runnableHere(entry) || compareVersions(entry.version, installed.version) <= 0) continue;
|
|
486
|
+
try {
|
|
487
|
+
const agent = await ensureAgent(id, { update: true });
|
|
488
|
+
await onUpdated?.(agent, installed.version);
|
|
489
|
+
} catch {
|
|
490
|
+
}
|
|
491
|
+
}
|
|
460
492
|
}
|
|
461
493
|
async function resolveAgentCommand(id, model) {
|
|
462
494
|
if (isLlmId(id)) {
|
|
@@ -529,6 +561,12 @@ async function install(entry) {
|
|
|
529
561
|
throw error;
|
|
530
562
|
}
|
|
531
563
|
const agent = { id: entry.id, name: entry.name, version: entry.version, dir, ...launch };
|
|
564
|
+
try {
|
|
565
|
+
await profileFor(entry.id)?.prepare?.(agent);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
rmSync(dir, { recursive: true, force: true });
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
532
570
|
writeFileSync3(join3(dir, MANIFEST), `${JSON.stringify(agent, null, 2)}
|
|
533
571
|
`);
|
|
534
572
|
return agent;
|
|
@@ -1050,7 +1088,11 @@ function checkTimeoutMs() {
|
|
|
1050
1088
|
const value = Number(process.env.LEURIA_CHECK_TIMEOUT_MS);
|
|
1051
1089
|
return Number.isFinite(value) && value > 0 ? value : 15e3;
|
|
1052
1090
|
}
|
|
1053
|
-
|
|
1091
|
+
function signInStallMs() {
|
|
1092
|
+
const value = Number(process.env.LEURIA_SIGNIN_STALL_MS);
|
|
1093
|
+
return Number.isFinite(value) && value > 0 ? value : 1e4;
|
|
1094
|
+
}
|
|
1095
|
+
async function checkSignIn(id, onProgress, options = {}) {
|
|
1054
1096
|
if (isLlmId(id)) return checkLlm(id);
|
|
1055
1097
|
const { session, close } = await open(id, onProgress);
|
|
1056
1098
|
let timer;
|
|
@@ -1061,7 +1103,7 @@ async function checkSignIn(id, onProgress) {
|
|
|
1061
1103
|
});
|
|
1062
1104
|
try {
|
|
1063
1105
|
const status = await Promise.race([timedOut, check()]);
|
|
1064
|
-
rememberReady(id, status.ok);
|
|
1106
|
+
if (!options.quiet) rememberReady(id, status.ok);
|
|
1065
1107
|
return status;
|
|
1066
1108
|
} finally {
|
|
1067
1109
|
clearTimeout(timer);
|
|
@@ -1078,7 +1120,11 @@ async function checkSignIn(id, onProgress) {
|
|
|
1078
1120
|
}
|
|
1079
1121
|
async function signIn(id, options = {}) {
|
|
1080
1122
|
if (isLlmId(id)) return checkLlm(id);
|
|
1081
|
-
|
|
1123
|
+
let pageOpened = false;
|
|
1124
|
+
const link = options.onUrl && !options.terminal ? catchBrowserLinks((url) => {
|
|
1125
|
+
pageOpened = true;
|
|
1126
|
+
options.onUrl?.(url);
|
|
1127
|
+
}) : null;
|
|
1082
1128
|
let opened;
|
|
1083
1129
|
try {
|
|
1084
1130
|
opened = await open(id, options.onProgress, options.terminal, link?.env);
|
|
@@ -1086,11 +1132,26 @@ async function signIn(id, options = {}) {
|
|
|
1086
1132
|
link?.stop();
|
|
1087
1133
|
throw error;
|
|
1088
1134
|
}
|
|
1089
|
-
const { agent,
|
|
1135
|
+
const { agent, env, sandbox } = opened;
|
|
1090
1136
|
const close = () => {
|
|
1091
1137
|
opened.close();
|
|
1092
1138
|
link?.stop();
|
|
1093
1139
|
};
|
|
1140
|
+
async function authenticateOrStall(session, methodId) {
|
|
1141
|
+
const auth = session.authenticate(methodId);
|
|
1142
|
+
if (!link) return auth;
|
|
1143
|
+
let timer;
|
|
1144
|
+
const stalled = new Promise((resolve) => {
|
|
1145
|
+
timer = setTimeout(() => {
|
|
1146
|
+
if (!pageOpened) resolve("stalled");
|
|
1147
|
+
}, signInStallMs());
|
|
1148
|
+
});
|
|
1149
|
+
try {
|
|
1150
|
+
return await Promise.race([auth, stalled]);
|
|
1151
|
+
} finally {
|
|
1152
|
+
clearTimeout(timer);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1094
1155
|
const cancelled = new Promise((resolve) => {
|
|
1095
1156
|
const onAbort = () => {
|
|
1096
1157
|
resolve({ ok: false, detail: "cancelled", methods: [] });
|
|
@@ -1105,6 +1166,7 @@ async function signIn(id, options = {}) {
|
|
|
1105
1166
|
close();
|
|
1106
1167
|
}
|
|
1107
1168
|
async function run() {
|
|
1169
|
+
let session = opened.session;
|
|
1108
1170
|
const connected = await session.connect();
|
|
1109
1171
|
if (connected.error) return { ok: false, detail: `the agent did not start: ${connected.error}`, methods: [] };
|
|
1110
1172
|
const all = session.info?.authMethods ?? [];
|
|
@@ -1129,7 +1191,16 @@ async function signIn(id, options = {}) {
|
|
|
1129
1191
|
if (code !== 0) return { ok: false, detail: `the sign-in ended with code ${code}`, methods };
|
|
1130
1192
|
return checkSignIn(id, options.onProgress);
|
|
1131
1193
|
}
|
|
1132
|
-
|
|
1194
|
+
let auth = await authenticateOrStall(session, method.id);
|
|
1195
|
+
if (auth === "stalled") {
|
|
1196
|
+
opened.close();
|
|
1197
|
+
if (options.signal?.aborted) return { ok: false, detail: "cancelled", methods };
|
|
1198
|
+
opened = await open(id, options.onProgress, options.terminal, link?.env);
|
|
1199
|
+
session = opened.session;
|
|
1200
|
+
const again = await session.connect();
|
|
1201
|
+
if (again.error) return { ok: false, detail: `the agent did not start: ${again.error}`, methods };
|
|
1202
|
+
auth = await session.authenticate(method.id);
|
|
1203
|
+
}
|
|
1133
1204
|
if (auth.error) return { ok: false, detail: auth.error, methods };
|
|
1134
1205
|
const created = await session.newSession();
|
|
1135
1206
|
if (created.error) return { ok: false, detail: created.error, methods };
|
|
@@ -1224,8 +1295,18 @@ var GrantStore = class {
|
|
|
1224
1295
|
for (const listener of this.changedListeners) listener(grant.origin);
|
|
1225
1296
|
return true;
|
|
1226
1297
|
}
|
|
1227
|
-
/**
|
|
1228
|
-
|
|
1298
|
+
/** Replace the site's skills (it changed its list). Its open conversations keep the skills they started with. */
|
|
1299
|
+
setSkills(origin, skills) {
|
|
1300
|
+
this.reload();
|
|
1301
|
+
const grant = this.grants.find((g) => g.origin === normalizeOrigin(origin));
|
|
1302
|
+
if (!grant) return false;
|
|
1303
|
+
if (skills) grant.skills = skills;
|
|
1304
|
+
else delete grant.skills;
|
|
1305
|
+
this.save();
|
|
1306
|
+
return true;
|
|
1307
|
+
}
|
|
1308
|
+
/** Create or replace the origin's grant; returns the new token. Keeps the site's AI and model choice; `needs` and `skills` replace what it declared. */
|
|
1309
|
+
create(origin, app, needs, skills) {
|
|
1229
1310
|
this.reload();
|
|
1230
1311
|
const normalized = normalizeOrigin(origin);
|
|
1231
1312
|
const token = randomBytes(32).toString("base64url");
|
|
@@ -1238,7 +1319,8 @@ var GrantStore = class {
|
|
|
1238
1319
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1239
1320
|
...previous?.agent ? { agent: previous.agent } : {},
|
|
1240
1321
|
...previous?.model ? { model: previous.model } : {},
|
|
1241
|
-
...needs ? { needs } : {}
|
|
1322
|
+
...needs ? { needs } : {},
|
|
1323
|
+
...skills ? { skills } : {}
|
|
1242
1324
|
});
|
|
1243
1325
|
this.save();
|
|
1244
1326
|
return token;
|
|
@@ -1456,7 +1538,7 @@ function parseEmbedBody(body) {
|
|
|
1456
1538
|
}
|
|
1457
1539
|
|
|
1458
1540
|
// src/pairing.ts
|
|
1459
|
-
import { createHash as
|
|
1541
|
+
import { createHash as createHash5, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1460
1542
|
|
|
1461
1543
|
// src/needs.ts
|
|
1462
1544
|
var EFFORTS = ["light", "standard", "deep"];
|
|
@@ -1473,13 +1555,363 @@ function parseNeeds(input) {
|
|
|
1473
1555
|
return Object.keys(needs).length ? needs : void 0;
|
|
1474
1556
|
}
|
|
1475
1557
|
|
|
1558
|
+
// src/skills.ts
|
|
1559
|
+
import { createHash as createHash4 } from "crypto";
|
|
1560
|
+
import cacache from "cacache";
|
|
1561
|
+
|
|
1562
|
+
// src/skill-sources.ts
|
|
1563
|
+
import { createHash as createHash3 } from "crypto";
|
|
1564
|
+
import { lookup } from "dns/promises";
|
|
1565
|
+
import { mkdtemp, readdir, readFile, rm, stat } from "fs/promises";
|
|
1566
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
1567
|
+
import { extname, join as join6, relative, sep } from "path";
|
|
1568
|
+
import { downloadTemplate } from "giget";
|
|
1569
|
+
import ipaddr from "ipaddr.js";
|
|
1570
|
+
import { parse as parseYaml } from "yaml";
|
|
1571
|
+
var TEXT_FILES = /* @__PURE__ */ new Set([".md", ".markdown", ".txt", ".json", ".yaml", ".yml", ".csv"]);
|
|
1572
|
+
var MAX_SKILL_BYTES = 256 * 1024;
|
|
1573
|
+
var MAX_FILES = 50;
|
|
1574
|
+
var MAX_TOTAL_BYTES = 1024 * 1024;
|
|
1575
|
+
var MAX_DESCRIPTION = 1024;
|
|
1576
|
+
var FETCH_TIMEOUT_MS = 15e3;
|
|
1577
|
+
var MAX_DEPTH = 5;
|
|
1578
|
+
var WELL_KNOWN = [".well-known/agent-skills", ".well-known/skills"];
|
|
1579
|
+
var DISCOVERY_V2 = "https://schemas.agentskills.io/discovery/0.2.0/schema.json";
|
|
1580
|
+
var SKILL_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
1581
|
+
var REPO_PART = /^[A-Za-z0-9_.-]+$/;
|
|
1582
|
+
function parseSkillSource(input, origin) {
|
|
1583
|
+
let ref = input.trim();
|
|
1584
|
+
if (ref.startsWith("/") || /^https?:\/\//i.test(ref)) {
|
|
1585
|
+
const url = new URL(ref, origin);
|
|
1586
|
+
if (url.hostname === "github.com") return parseGithubUrl(url);
|
|
1587
|
+
if (url.origin !== origin) throw new Error(`${input}: skills come from GitHub or from the site's own origin (${origin})`);
|
|
1588
|
+
url.hash = "";
|
|
1589
|
+
return { kind: "site", url: url.toString() };
|
|
1590
|
+
}
|
|
1591
|
+
ref = ref.replace(/^github:/, "");
|
|
1592
|
+
let pin;
|
|
1593
|
+
let skill;
|
|
1594
|
+
const hash = ref.indexOf("#");
|
|
1595
|
+
if (hash >= 0) {
|
|
1596
|
+
const fragment = ref.slice(hash + 1);
|
|
1597
|
+
ref = ref.slice(0, hash);
|
|
1598
|
+
const at2 = fragment.indexOf("@");
|
|
1599
|
+
pin = (at2 >= 0 ? fragment.slice(0, at2) : fragment) || void 0;
|
|
1600
|
+
skill = at2 >= 0 ? fragment.slice(at2 + 1) || void 0 : void 0;
|
|
1601
|
+
}
|
|
1602
|
+
const at = ref.match(/^([^/]+)\/([^/@]+)@(.+)$/);
|
|
1603
|
+
if (at) return github(input, `${at[1]}/${at[2]}`, pin, void 0, skill ?? at[3]);
|
|
1604
|
+
const parts = ref.split("/").filter(Boolean);
|
|
1605
|
+
if (parts.length < 2) throw new Error(`${input}: expected owner/repo, owner/repo@skill, a GitHub URL, or a URL on the site`);
|
|
1606
|
+
return github(input, `${parts[0]}/${parts[1]}`, pin, parts.slice(2).join("/") || void 0, skill);
|
|
1607
|
+
}
|
|
1608
|
+
function parseGithubUrl(url) {
|
|
1609
|
+
const [owner, repo, marker, pin, ...rest] = url.pathname.split("/").filter(Boolean);
|
|
1610
|
+
if (!owner || !repo) throw new Error(`${url}: not a GitHub repository`);
|
|
1611
|
+
const tree = marker === "tree" && pin;
|
|
1612
|
+
return github(url.toString(), `${owner}/${repo.replace(/\.git$/, "")}`, tree ? pin : void 0, tree ? rest.join("/") || void 0 : void 0, void 0);
|
|
1613
|
+
}
|
|
1614
|
+
function github(input, repo, ref, subpath, skill) {
|
|
1615
|
+
const [owner, name] = repo.split("/");
|
|
1616
|
+
if (![owner, name].every((part) => part && REPO_PART.test(part) && !/^\.+$/.test(part))) throw new Error(`${input}: not a GitHub repository`);
|
|
1617
|
+
if (subpath && subpath.split("/").some((p) => p === ".." || p === "." || !p)) throw new Error(`${input}: bad folder`);
|
|
1618
|
+
if (ref && !/^[\w./-]+$/.test(ref)) throw new Error(`${input}: bad ref`);
|
|
1619
|
+
return { kind: "github", repo, ...ref ? { ref } : {}, ...subpath ? { subpath } : {}, ...skill ? { skill } : {} };
|
|
1620
|
+
}
|
|
1621
|
+
function sourceLabel(source) {
|
|
1622
|
+
return source.kind === "github" ? source.repo : new URL(source.url).host;
|
|
1623
|
+
}
|
|
1624
|
+
async function fetchSkills(source) {
|
|
1625
|
+
const skills = source.kind === "github" ? await fromGithub(source) : await fromSite(source.url);
|
|
1626
|
+
if (!skills.length) throw new Error("no skill found");
|
|
1627
|
+
return skills;
|
|
1628
|
+
}
|
|
1629
|
+
function parseSkillMd(text, files = {}) {
|
|
1630
|
+
if (text.length > MAX_SKILL_BYTES) throw new Error("SKILL.md is too long");
|
|
1631
|
+
const match = text.replace(/^/, "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
1632
|
+
if (!match) throw new Error("SKILL.md has no front matter");
|
|
1633
|
+
const meta = parseYaml(match[1]);
|
|
1634
|
+
const name = typeof meta?.name === "string" ? meta.name.trim() : "";
|
|
1635
|
+
const description = typeof meta?.description === "string" ? meta.description.trim().replace(/\s+/g, " ") : "";
|
|
1636
|
+
if (!SKILL_NAME.test(name) || name.length > 64) throw new Error(`SKILL.md name "${name}" is not a skill name`);
|
|
1637
|
+
if (!description) throw new Error(`SKILL.md of ${name} has no description`);
|
|
1638
|
+
return { name, description: description.slice(0, MAX_DESCRIPTION), body: match[2].trim(), files };
|
|
1639
|
+
}
|
|
1640
|
+
async function fromGithub(source) {
|
|
1641
|
+
const dir = await mkdtemp(join6(tmpdir3(), "leuria-skills-"));
|
|
1642
|
+
try {
|
|
1643
|
+
const path2 = source.subpath ? `/${source.subpath}` : "";
|
|
1644
|
+
await downloadTemplate(`gh:${source.repo}${path2}#${source.ref ?? "HEAD"}`, {
|
|
1645
|
+
dir,
|
|
1646
|
+
force: true,
|
|
1647
|
+
forceClean: true,
|
|
1648
|
+
registry: false,
|
|
1649
|
+
silent: true,
|
|
1650
|
+
ignore: (file) => !TEXT_FILES.has(extname(file).toLowerCase())
|
|
1651
|
+
});
|
|
1652
|
+
const found = [];
|
|
1653
|
+
for (const skillFile of await findSkillFiles(dir, 0)) {
|
|
1654
|
+
let skill;
|
|
1655
|
+
try {
|
|
1656
|
+
skill = parseSkillMd(await readFile(skillFile, "utf-8"));
|
|
1657
|
+
} catch {
|
|
1658
|
+
continue;
|
|
1659
|
+
}
|
|
1660
|
+
if (source.skill && skill.name !== source.skill) continue;
|
|
1661
|
+
skill.files = await readTextFiles(join6(skillFile, ".."));
|
|
1662
|
+
found.push(skill);
|
|
1663
|
+
}
|
|
1664
|
+
return found;
|
|
1665
|
+
} finally {
|
|
1666
|
+
await rm(dir, { recursive: true, force: true });
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
async function findSkillFiles(dir, depth) {
|
|
1670
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1671
|
+
const own = entries.find((e) => e.isFile() && e.name.toLowerCase() === "skill.md");
|
|
1672
|
+
if (own) return [join6(dir, own.name)];
|
|
1673
|
+
if (depth >= MAX_DEPTH) return [];
|
|
1674
|
+
const nested = await Promise.all(
|
|
1675
|
+
entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules").map((e) => findSkillFiles(join6(dir, e.name), depth + 1))
|
|
1676
|
+
);
|
|
1677
|
+
return nested.flat();
|
|
1678
|
+
}
|
|
1679
|
+
async function readTextFiles(dir) {
|
|
1680
|
+
const files = {};
|
|
1681
|
+
let total = 0;
|
|
1682
|
+
const walk = async (at, depth) => {
|
|
1683
|
+
for (const entry of await readdir(at, { withFileTypes: true })) {
|
|
1684
|
+
const full = join6(at, entry.name);
|
|
1685
|
+
if (entry.isDirectory() && depth < MAX_DEPTH) await walk(full, depth + 1);
|
|
1686
|
+
if (!entry.isFile() || !TEXT_FILES.has(extname(entry.name).toLowerCase())) continue;
|
|
1687
|
+
const path2 = relative(dir, full).split(sep).join("/");
|
|
1688
|
+
if (path2.toLowerCase() === "skill.md" || Object.keys(files).length >= MAX_FILES) continue;
|
|
1689
|
+
const size = (await stat(full)).size;
|
|
1690
|
+
if (size > MAX_SKILL_BYTES || total + size > MAX_TOTAL_BYTES) continue;
|
|
1691
|
+
total += size;
|
|
1692
|
+
files[path2] = await readFile(full, "utf-8");
|
|
1693
|
+
}
|
|
1694
|
+
};
|
|
1695
|
+
await walk(dir, 0);
|
|
1696
|
+
return files;
|
|
1697
|
+
}
|
|
1698
|
+
async function fromSite(url) {
|
|
1699
|
+
const parsed = new URL(url);
|
|
1700
|
+
if (/\/skill\.md$/i.test(parsed.pathname)) return [parseSkillMd(await siteText(url))];
|
|
1701
|
+
const named2 = parsed.pathname.match(/\/\.well-known\/(?:agent-skills|skills)\/([^/]+)\/?$/)?.[1];
|
|
1702
|
+
const base = parsed.pathname.replace(/\/\.well-known\/.*$/, "").replace(/\/$/, "");
|
|
1703
|
+
for (const wellKnown of WELL_KNOWN) {
|
|
1704
|
+
const indexUrl = `${parsed.origin}${base}/${wellKnown}/index.json`;
|
|
1705
|
+
let index;
|
|
1706
|
+
try {
|
|
1707
|
+
index = JSON.parse(await siteText(indexUrl));
|
|
1708
|
+
} catch {
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
const entries = indexEntries(index).filter((e) => !named2 || e.name === named2);
|
|
1712
|
+
const skills = [];
|
|
1713
|
+
for (const entry of entries) {
|
|
1714
|
+
try {
|
|
1715
|
+
skills.push(await fromIndexEntry(entry, indexUrl, `${parsed.origin}${base}/${wellKnown}/${entry.name}`));
|
|
1716
|
+
} catch {
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
return skills;
|
|
1720
|
+
}
|
|
1721
|
+
throw new Error("the site has no .well-known/agent-skills/index.json");
|
|
1722
|
+
}
|
|
1723
|
+
function indexEntries(index) {
|
|
1724
|
+
const record = index;
|
|
1725
|
+
if (!record || !Array.isArray(record.skills)) return [];
|
|
1726
|
+
const v2 = record.$schema === DISCOVERY_V2;
|
|
1727
|
+
return record.skills.flatMap((raw) => {
|
|
1728
|
+
const e = raw;
|
|
1729
|
+
if (typeof e.name !== "string" || !SKILL_NAME.test(e.name)) return [];
|
|
1730
|
+
if (v2) {
|
|
1731
|
+
if (e.type !== "skill-md" || typeof e.url !== "string" || typeof e.digest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(e.digest)) return [];
|
|
1732
|
+
return [{ name: e.name, url: e.url, digest: e.digest }];
|
|
1733
|
+
}
|
|
1734
|
+
const files = Array.isArray(e.files) ? e.files.filter((f) => typeof f === "string" && !f.startsWith("/") && !f.includes("..")) : [];
|
|
1735
|
+
return files.some((f) => f.toLowerCase() === "skill.md") ? [{ name: e.name, files }] : [];
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
async function fromIndexEntry(entry, indexUrl, folder) {
|
|
1739
|
+
if (entry.url) {
|
|
1740
|
+
const text = await siteText(new URL(entry.url, indexUrl).toString());
|
|
1741
|
+
const digest = `sha256:${createHash3("sha256").update(text).digest("hex")}`;
|
|
1742
|
+
if (digest !== entry.digest) throw new Error(`${entry.name}: SKILL.md doesn't match its digest`);
|
|
1743
|
+
return named(parseSkillMd(text), entry.name);
|
|
1744
|
+
}
|
|
1745
|
+
const files = {};
|
|
1746
|
+
let skillMd = "";
|
|
1747
|
+
for (const file of (entry.files ?? []).slice(0, MAX_FILES)) {
|
|
1748
|
+
const text = await siteText(`${folder}/${file}`);
|
|
1749
|
+
if (file.toLowerCase() === "skill.md") skillMd = text;
|
|
1750
|
+
else if (TEXT_FILES.has(extname(file).toLowerCase())) files[file] = text;
|
|
1751
|
+
}
|
|
1752
|
+
return named(parseSkillMd(skillMd, files), entry.name);
|
|
1753
|
+
}
|
|
1754
|
+
function named(skill, name) {
|
|
1755
|
+
if (skill.name !== name) throw new Error(`the index names ${name}, its SKILL.md ${skill.name}`);
|
|
1756
|
+
return skill;
|
|
1757
|
+
}
|
|
1758
|
+
async function siteText(url) {
|
|
1759
|
+
const { hostname } = new URL(url);
|
|
1760
|
+
const host = hostname.replace(/^\[|\]$/g, "");
|
|
1761
|
+
if (!isLoopbackName(host)) {
|
|
1762
|
+
const addresses = ipaddr.isValid(host) ? [{ address: host }] : await lookup(host, { all: true });
|
|
1763
|
+
for (const { address } of addresses) {
|
|
1764
|
+
const range = ipaddr.process(address).range();
|
|
1765
|
+
if (range !== "unicast") throw new Error(`${hostname} resolves to a ${range} address`);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
const res = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
1769
|
+
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
|
1770
|
+
if (Number(res.headers.get("content-length")) > MAX_SKILL_BYTES) throw new Error(`${url} is too large`);
|
|
1771
|
+
const text = await res.text();
|
|
1772
|
+
if (text.length > MAX_SKILL_BYTES) throw new Error(`${url} is too large`);
|
|
1773
|
+
return text;
|
|
1774
|
+
}
|
|
1775
|
+
function isLoopbackName(host) {
|
|
1776
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
1777
|
+
return ipaddr.isValid(host) && ipaddr.process(host).range() === "loopback";
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// src/skills.ts
|
|
1781
|
+
var READ_SKILL = "read_skill";
|
|
1782
|
+
var MAX_REFS = 16;
|
|
1783
|
+
var MAX_SKILLS = 16;
|
|
1784
|
+
var MAX_REF_LENGTH = 300;
|
|
1785
|
+
function parseSkillRefs(input) {
|
|
1786
|
+
const raw = typeof input === "string" ? [input] : Array.isArray(input) ? input : [];
|
|
1787
|
+
const refs = [...new Set(raw.filter((r) => typeof r === "string").map((r) => r.trim()).filter((r) => r && r.length <= MAX_REF_LENGTH))];
|
|
1788
|
+
return refs.length ? refs.slice(0, MAX_REFS) : void 0;
|
|
1789
|
+
}
|
|
1790
|
+
var sameRefs = (a, b) => (a ?? []).join("\n") === (b ?? []).join("\n");
|
|
1791
|
+
var SkillService = class {
|
|
1792
|
+
constructor(options) {
|
|
1793
|
+
this.options = options;
|
|
1794
|
+
this.cache = options.cache === void 0 ? homePath("skills") : options.cache;
|
|
1795
|
+
}
|
|
1796
|
+
options;
|
|
1797
|
+
cache;
|
|
1798
|
+
memory = /* @__PURE__ */ new Map();
|
|
1799
|
+
refreshing = /* @__PURE__ */ new Map();
|
|
1800
|
+
/**
|
|
1801
|
+
* Fetch and cache the skills `refs` name for `origin`. A ref that fails
|
|
1802
|
+
* is logged and skipped; the first skill of a name wins.
|
|
1803
|
+
*/
|
|
1804
|
+
async resolve(origin, refs) {
|
|
1805
|
+
const list = [];
|
|
1806
|
+
for (const ref of refs) {
|
|
1807
|
+
let found;
|
|
1808
|
+
try {
|
|
1809
|
+
found = await (this.options.fetch ?? fetchRef)(ref, origin);
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
this.options.logger.warn("skill source failed", { origin, ref, error: error instanceof Error ? error.message : String(error) });
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
for (const skill of found.skills) {
|
|
1815
|
+
if (list.length >= MAX_SKILLS || list.some((s) => s.name === skill.name)) continue;
|
|
1816
|
+
list.push({ name: skill.name, description: skill.description, source: found.source, shared: found.shared, integrity: await this.put(skill) });
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
return list;
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* A connected site sent its skill refs with a session: when they changed,
|
|
1823
|
+
* fetch them in the background and update its grant. Sessions starting
|
|
1824
|
+
* meanwhile keep the skills it had.
|
|
1825
|
+
*/
|
|
1826
|
+
refresh(origin, refs) {
|
|
1827
|
+
const grant = this.options.grants.get(origin);
|
|
1828
|
+
if (!grant || sameRefs(grant.skills?.refs, refs) || this.refreshing.has(origin)) return;
|
|
1829
|
+
const done = (async () => {
|
|
1830
|
+
const list = refs ? await this.resolve(origin, refs) : [];
|
|
1831
|
+
const before = new Set((this.options.grants.get(origin)?.skills?.list ?? []).map((s) => s.name));
|
|
1832
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1833
|
+
const marked = list.map((s) => before.has(s.name) ? s : { ...s, addedAt: now });
|
|
1834
|
+
this.options.grants.setSkills(origin, refs ? { refs, list: marked } : void 0);
|
|
1835
|
+
})().catch((error) => this.options.logger.warn("skills refresh failed", { origin, error: String(error) })).finally(() => this.refreshing.delete(origin));
|
|
1836
|
+
this.refreshing.set(origin, done);
|
|
1837
|
+
}
|
|
1838
|
+
/** The skills a session of `origin` gets, from the cache. */
|
|
1839
|
+
async forSession(origin) {
|
|
1840
|
+
const list = this.options.grants.get(origin)?.skills?.list ?? [];
|
|
1841
|
+
const loaded = await Promise.all(list.map((s) => this.get(s.integrity).catch(() => null)));
|
|
1842
|
+
return loaded.filter((s) => s !== null);
|
|
1843
|
+
}
|
|
1844
|
+
/** Wait for background refreshes (tests). */
|
|
1845
|
+
async settled() {
|
|
1846
|
+
await Promise.all(this.refreshing.values());
|
|
1847
|
+
}
|
|
1848
|
+
async put(skill) {
|
|
1849
|
+
const data = JSON.stringify(skill);
|
|
1850
|
+
if (this.cache === null) {
|
|
1851
|
+
const integrity = `sha256-${createHash4("sha256").update(data).digest("base64")}`;
|
|
1852
|
+
this.memory.set(integrity, skill);
|
|
1853
|
+
return integrity;
|
|
1854
|
+
}
|
|
1855
|
+
return String(await cacache.put(this.cache, `skill:${skill.name}`, data, { algorithms: ["sha256"] }));
|
|
1856
|
+
}
|
|
1857
|
+
async get(integrity) {
|
|
1858
|
+
if (this.cache === null) {
|
|
1859
|
+
const skill = this.memory.get(integrity);
|
|
1860
|
+
if (!skill) throw new Error("not cached");
|
|
1861
|
+
return skill;
|
|
1862
|
+
}
|
|
1863
|
+
return JSON.parse((await cacache.get.byDigest(this.cache, integrity)).toString("utf-8"));
|
|
1864
|
+
}
|
|
1865
|
+
};
|
|
1866
|
+
async function fetchRef(ref, origin) {
|
|
1867
|
+
const source = parseSkillSource(ref, origin);
|
|
1868
|
+
return { skills: await fetchSkills(source), source: sourceLabel(source), shared: source.kind === "github" };
|
|
1869
|
+
}
|
|
1870
|
+
function skillsPrompt(skills) {
|
|
1871
|
+
return [
|
|
1872
|
+
"This site gives you skills: instructions for tasks on this site.",
|
|
1873
|
+
`When a request matches a skill, call \`${READ_SKILL}\` with its name first and follow what it says.`,
|
|
1874
|
+
"",
|
|
1875
|
+
...skills.map((s) => `- ${s.name}: ${s.description}`)
|
|
1876
|
+
].join("\n");
|
|
1877
|
+
}
|
|
1878
|
+
function readSkillTool(skills) {
|
|
1879
|
+
return {
|
|
1880
|
+
name: READ_SKILL,
|
|
1881
|
+
description: "Read one of this site's skills: its instructions, or one of its files.",
|
|
1882
|
+
inputSchema: {
|
|
1883
|
+
type: "object",
|
|
1884
|
+
properties: {
|
|
1885
|
+
name: { type: "string", enum: skills.map((s) => s.name), description: "The skill's name." },
|
|
1886
|
+
file: { type: "string", description: "A file the skill mentions, by its path. Leave out for the skill's instructions." }
|
|
1887
|
+
},
|
|
1888
|
+
required: ["name"]
|
|
1889
|
+
},
|
|
1890
|
+
annotations: { readOnlyHint: true }
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
function readSkill(skills, args) {
|
|
1894
|
+
const skill = skills.find((s) => s.name === args.name);
|
|
1895
|
+
if (!skill) throw new Error(`No skill named ${String(args.name)}. Skills: ${skills.map((s) => s.name).join(", ")}`);
|
|
1896
|
+
if (typeof args.file === "string" && args.file) {
|
|
1897
|
+
const file = skill.files[args.file.replace(/^\.\//, "")];
|
|
1898
|
+
if (file === void 0) throw new Error(`${skill.name} has no file ${args.file}`);
|
|
1899
|
+
return file;
|
|
1900
|
+
}
|
|
1901
|
+
const files = Object.keys(skill.files);
|
|
1902
|
+
return files.length ? `${skill.body}
|
|
1903
|
+
|
|
1904
|
+
---
|
|
1905
|
+
Files of this skill (read one with \`file\`): ${files.join(", ")}` : skill.body;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1476
1908
|
// src/pairing.ts
|
|
1477
1909
|
var REQUEST_TTL_MS = 5 * 6e4;
|
|
1478
1910
|
var MAX_PENDING = 20;
|
|
1479
1911
|
var WAIT_MS = 25e3;
|
|
1480
1912
|
var DENIED_COOLDOWN_MS = 3e4;
|
|
1481
1913
|
var NONCE = /^[A-Za-z0-9_-]{22,128}$/;
|
|
1482
|
-
var hashNonce = (nonce) =>
|
|
1914
|
+
var hashNonce = (nonce) => createHash5("sha256").update(nonce).digest();
|
|
1483
1915
|
var Pairing = class {
|
|
1484
1916
|
constructor(options) {
|
|
1485
1917
|
this.options = options;
|
|
@@ -1508,7 +1940,7 @@ var Pairing = class {
|
|
|
1508
1940
|
if (this.options.selfOrigins.has(origin)) return { error: "The link doesn't name a website." };
|
|
1509
1941
|
if (typeof input.nonce !== "string" || !NONCE.test(input.nonce)) return { error: "The link is incomplete." };
|
|
1510
1942
|
const app = typeof input.app === "string" && input.app.trim() ? input.app.trim().slice(0, 80) : void 0;
|
|
1511
|
-
const request = this.open(origin, app, input.nonce, parseNeeds(input.needs));
|
|
1943
|
+
const request = this.open(origin, app, input.nonce, parseNeeds(input.needs), parseSkillRefs(input.skills));
|
|
1512
1944
|
return "error" in request ? request : { requestId: request.id };
|
|
1513
1945
|
}
|
|
1514
1946
|
/** Returns true when the request was a pairing route. */
|
|
@@ -1534,7 +1966,7 @@ var Pairing = class {
|
|
|
1534
1966
|
sendJson(res, 403, { error: "Forbidden" });
|
|
1535
1967
|
return true;
|
|
1536
1968
|
}
|
|
1537
|
-
sendJson(res, 200, { decision: request?.decision ?? "expired" });
|
|
1969
|
+
sendJson(res, 200, { decision: request?.decision ?? "expired", ...request?.skills && !request.skills.list ? { skillsLoading: true } : {} });
|
|
1538
1970
|
return true;
|
|
1539
1971
|
}
|
|
1540
1972
|
if (req.method === "POST" && action === "decide") {
|
|
@@ -1579,7 +2011,7 @@ var Pairing = class {
|
|
|
1579
2011
|
return;
|
|
1580
2012
|
}
|
|
1581
2013
|
const app = typeof body.app === "string" && body.app.trim() ? body.app.trim().slice(0, 80) : void 0;
|
|
1582
|
-
const opened = this.open(origin, app, nonce, parseNeeds(body.needs));
|
|
2014
|
+
const opened = this.open(origin, app, nonce, parseNeeds(body.needs), parseSkillRefs(body.skills));
|
|
1583
2015
|
if ("error" in opened) {
|
|
1584
2016
|
sendJson(res, opened.status, { error: opened.error });
|
|
1585
2017
|
return;
|
|
@@ -1604,7 +2036,7 @@ var Pairing = class {
|
|
|
1604
2036
|
}
|
|
1605
2037
|
}
|
|
1606
2038
|
/** Ask the visitor about `origin`, or update the question already asked. */
|
|
1607
|
-
open(origin, app, nonce, needs) {
|
|
2039
|
+
open(origin, app, nonce, needs, skillRefs) {
|
|
1608
2040
|
const denied = this.deniedAt.get(origin);
|
|
1609
2041
|
if (denied && Date.now() - denied < DENIED_COOLDOWN_MS) return { error: "The visitor just said no to this site", status: 429 };
|
|
1610
2042
|
const existing = [...this.requests.values()].find((r) => r.origin === origin && r.decision === "pending");
|
|
@@ -1612,6 +2044,7 @@ var Pairing = class {
|
|
|
1612
2044
|
existing.nonce = hashNonce(nonce);
|
|
1613
2045
|
if (app) existing.app = app;
|
|
1614
2046
|
if (needs) existing.needs = needs;
|
|
2047
|
+
if (skillRefs && !sameRefs(existing.skills?.refs, skillRefs)) this.fetchSkills(existing, skillRefs);
|
|
1615
2048
|
this.options.onRequest?.(this.info(existing));
|
|
1616
2049
|
return existing;
|
|
1617
2050
|
}
|
|
@@ -1629,16 +2062,35 @@ var Pairing = class {
|
|
|
1629
2062
|
waiters: /* @__PURE__ */ new Set()
|
|
1630
2063
|
};
|
|
1631
2064
|
this.requests.set(request.id, request);
|
|
2065
|
+
if (skillRefs) this.fetchSkills(request, skillRefs);
|
|
1632
2066
|
this.options.logger.info("pairing requested", { origin, app });
|
|
1633
2067
|
this.options.onRequest?.(this.info(request));
|
|
1634
2068
|
return request;
|
|
1635
2069
|
}
|
|
2070
|
+
/**
|
|
2071
|
+
* Fetch the skills the site names, then show them: the question is asked
|
|
2072
|
+
* again with them. Answered meanwhile: they go to the site's grant.
|
|
2073
|
+
*/
|
|
2074
|
+
fetchSkills(request, refs) {
|
|
2075
|
+
const { resolveSkills } = this.options;
|
|
2076
|
+
if (!resolveSkills) return;
|
|
2077
|
+
const skills = { refs };
|
|
2078
|
+
request.skills = skills;
|
|
2079
|
+
void resolveSkills(request.origin, refs).catch(() => []).then((list) => {
|
|
2080
|
+
if (request.skills !== skills) return;
|
|
2081
|
+
skills.list = list;
|
|
2082
|
+
if (request.decision === "pending") this.options.onRequest?.(this.info(request));
|
|
2083
|
+
else if (request.decision === "allowed") this.options.grants.setSkills(request.origin, { refs, list });
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
1636
2086
|
info(request) {
|
|
2087
|
+
const skills = request.skills;
|
|
1637
2088
|
return {
|
|
1638
2089
|
requestId: request.id,
|
|
1639
2090
|
origin: request.origin,
|
|
1640
2091
|
app: request.app,
|
|
1641
2092
|
...request.needs ? { needs: request.needs } : {},
|
|
2093
|
+
...skills ? { skills: { loading: !skills.list, list: (skills.list ?? []).map(({ name, description, source, shared }) => ({ name, description, source, shared })) } } : {},
|
|
1642
2094
|
approveUrl: `http://127.0.0.1:${this.options.port}/connect/${request.id}`
|
|
1643
2095
|
};
|
|
1644
2096
|
}
|
|
@@ -1661,7 +2113,8 @@ var Pairing = class {
|
|
|
1661
2113
|
}
|
|
1662
2114
|
decide(request, allow, granted) {
|
|
1663
2115
|
if (allow) {
|
|
1664
|
-
|
|
2116
|
+
const skills = request.skills ? { refs: request.skills.refs, list: request.skills.list ?? [] } : void 0;
|
|
2117
|
+
request.token = this.options.grants.create(request.origin, request.app, request.needs, skills);
|
|
1665
2118
|
granted?.(request.origin);
|
|
1666
2119
|
request.decision = "allowed";
|
|
1667
2120
|
} else {
|
|
@@ -1750,6 +2203,15 @@ button.ink:hover { background: var(--ink-hover); }
|
|
|
1750
2203
|
button:disabled { opacity: 0.5; cursor: default; }
|
|
1751
2204
|
button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
1752
2205
|
.small { font-size: 12px; }
|
|
2206
|
+
.skills { border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; font-size: 14px; }
|
|
2207
|
+
.skills summary { cursor: pointer; display: flex; justify-content: space-between; gap: 8px; list-style: none; }
|
|
2208
|
+
.skills summary::-webkit-details-marker { display: none; }
|
|
2209
|
+
.skills summary span:last-child { color: var(--muted); white-space: nowrap; }
|
|
2210
|
+
.skills[open] summary span:last-child::after { content: " \\25B4"; }
|
|
2211
|
+
.skills:not([open]) summary span:last-child::after { content: " \\25BE"; }
|
|
2212
|
+
.skills ul { margin-top: 10px; gap: 10px; }
|
|
2213
|
+
.skills li { display: block; }
|
|
2214
|
+
.skills li p { font-size: 13px; }
|
|
1753
2215
|
`;
|
|
1754
2216
|
var MARK = "M181 61 C184 74 191 82 206 86 C191 90 184 98 181 111 C178 98 171 90 156 86 C171 82 178 74 181 61 Z M59 92 C60 109 67 128 79 143 C91 158 105 168 124 174 C149 182 164 193 171 207 C177 219 177 239 177 255 C182 255 182 240 184 226 C188 199 201 187 223 179 C247 171 270 158 284 142 C298 127 303 109 303 92 C298 91 292 101 284 108 C265 125 245 130 226 126 C217 124 211 114 205 112 C199 110 199 116 199 121 C198 127 191 130 181 130 C171 130 165 127 163 121 C162 116 165 110 159 111 C151 112 148 122 137 126 C118 131 98 125 78 109 C69 102 65 91 59 92 Z";
|
|
1755
2217
|
var LOGO = `<span class="logo" role="img" aria-label="Leuria"><svg width="22" height="22" viewBox="53 30 256 256" aria-hidden="true"><path d="${MARK}" fill="currentColor"/></svg>leuria</span>`;
|
|
@@ -1774,6 +2236,7 @@ function approvePage(request, agentName2) {
|
|
|
1774
2236
|
<p>It will talk to ${agent}, on this computer.</p>
|
|
1775
2237
|
<div><div class="eyebrow">It can</div><ul class="can"><li>${CHECK}Ask your AI to answer you</li><li>${CHECK}Let your AI use its own page's tools</li></ul></div>
|
|
1776
2238
|
<div><div class="eyebrow">It can't</div><ul class="cant"><li>${CROSS}See your files or run programs</li><li>${CROSS}See what you do on other websites</li></ul></div>
|
|
2239
|
+
${skillsSection(request)}
|
|
1777
2240
|
${insecure ? `<p class="warn">This site doesn't use a secure connection.</p>` : ""}
|
|
1778
2241
|
<p class="small">You can disconnect it at any time with <code>leuria sites revoke ${escapeHtml(request.origin)}</code>.</p>
|
|
1779
2242
|
<div class="actions"><button type="button" class="soft" id="deny">Not now</button><button type="button" class="ink" id="allow">Allow</button></div>`;
|
|
@@ -1794,14 +2257,25 @@ async function decide(allow) {
|
|
|
1794
2257
|
}
|
|
1795
2258
|
document.getElementById("allow").addEventListener("click", () => decide(true));
|
|
1796
2259
|
document.getElementById("deny").addEventListener("click", () => decide(false));
|
|
1797
|
-
// Answered in the Leuria app instead: close this window too.
|
|
2260
|
+
// Answered in the Leuria app instead: close this window too. The site's skills fetched: show them.
|
|
2261
|
+
const skillsLoading = ${request.skills && !request.skills.list ? "true" : "false"};
|
|
1798
2262
|
setInterval(async () => {
|
|
1799
2263
|
const res = await fetch(location.pathname + "/state").catch(() => null);
|
|
1800
2264
|
const state = res && res.ok ? await res.json() : null;
|
|
1801
2265
|
if (state && state.decision !== "pending") done("Answered in Leuria", "You can close this window.");
|
|
2266
|
+
else if (state && skillsLoading && !state.skillsLoading) location.reload();
|
|
1802
2267
|
}, 1500);`;
|
|
1803
2268
|
return page("Connect your AI", body, script);
|
|
1804
2269
|
}
|
|
2270
|
+
function skillsSection(request) {
|
|
2271
|
+
const skills = request.skills;
|
|
2272
|
+
if (!skills) return "";
|
|
2273
|
+
if (!skills.list) return `<p>Checking the skills this site uses to guide your AI\u2026</p>`;
|
|
2274
|
+
if (!skills.list.length) return "";
|
|
2275
|
+
const count = skills.list.length === 1 ? "1 skill" : `${skills.list.length} skills`;
|
|
2276
|
+
const items = skills.list.map((s) => `<li><strong>${escapeHtml(s.name)}</strong><p>${escapeHtml(s.description)}</p><p class="small">${s.shared ? `Shared skill \xB7 ${escapeHtml(s.source)}` : `From ${escapeHtml(s.source)}`}</p></li>`).join("");
|
|
2277
|
+
return `<details class="skills"><summary><span>This site uses ${count} to guide your AI.</span><span>See details</span></summary><ul>${items}</ul></details>`;
|
|
2278
|
+
}
|
|
1805
2279
|
function messagePage(title, text) {
|
|
1806
2280
|
return page(title, `<h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>`);
|
|
1807
2281
|
}
|
|
@@ -1832,12 +2306,12 @@ var POST_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
1832
2306
|
"cancel",
|
|
1833
2307
|
"close"
|
|
1834
2308
|
]);
|
|
1835
|
-
async function handleSessionRequest(req, res, pathname, sm, requester) {
|
|
2309
|
+
async function handleSessionRequest(req, res, pathname, sm, requester, onSkills) {
|
|
1836
2310
|
const segments = pathname.split("/").filter(Boolean);
|
|
1837
2311
|
if (segments[0] !== "session") return false;
|
|
1838
2312
|
try {
|
|
1839
2313
|
if (req.method === "POST" && segments.length === 2 && segments[1] === "prepare") {
|
|
1840
|
-
await handlePrepare(req, res, sm, requester);
|
|
2314
|
+
await handlePrepare(req, res, sm, requester, onSkills);
|
|
1841
2315
|
return true;
|
|
1842
2316
|
}
|
|
1843
2317
|
const sessionId = segments[1];
|
|
@@ -1886,8 +2360,9 @@ async function runAction(req, sm, sessionId, action) {
|
|
|
1886
2360
|
return sm.close(sessionId);
|
|
1887
2361
|
}
|
|
1888
2362
|
}
|
|
1889
|
-
async function handlePrepare(req, res, sm, origin) {
|
|
2363
|
+
async function handlePrepare(req, res, sm, origin, onSkills) {
|
|
1890
2364
|
const body = await parseBody(req);
|
|
2365
|
+
if ("skills" in body) onSkills?.(origin, parseSkillRefs(body.skills));
|
|
1891
2366
|
if (body.prompt !== void 0 && (typeof body.prompt !== "string" || !body.prompt.trim())) {
|
|
1892
2367
|
sendJson(res, 400, { error: "prompt must be a non-empty string when given" });
|
|
1893
2368
|
return;
|
|
@@ -1938,8 +2413,8 @@ function handleStream(req, res, sm, sessionId) {
|
|
|
1938
2413
|
// src/session-manager.ts
|
|
1939
2414
|
import { randomUUID } from "crypto";
|
|
1940
2415
|
import { mkdirSync as mkdirSync6, rmSync as rmSync4 } from "fs";
|
|
1941
|
-
import { tmpdir as
|
|
1942
|
-
import { join as
|
|
2416
|
+
import { tmpdir as tmpdir4 } from "os";
|
|
2417
|
+
import { join as join7 } from "path";
|
|
1943
2418
|
|
|
1944
2419
|
// src/llm/llm-session.ts
|
|
1945
2420
|
var LlmSession = class {
|
|
@@ -2260,6 +2735,12 @@ var SessionManager = class {
|
|
|
2260
2735
|
return;
|
|
2261
2736
|
}
|
|
2262
2737
|
if (TERMINAL.has(session.status)) return;
|
|
2738
|
+
const skills = await this.options.loadSkills?.(session.params.origin).catch(() => []) ?? [];
|
|
2739
|
+
if (skills.length) {
|
|
2740
|
+
this.options.webMcpServer.setSkills(session.id, skills);
|
|
2741
|
+
session.params.systemPrompt = [session.params.systemPrompt, skillsPrompt(skills)].filter(Boolean).join("\n\n");
|
|
2742
|
+
}
|
|
2743
|
+
if (TERMINAL.has(session.status)) return;
|
|
2263
2744
|
const live = launch.llm ? this.llmSession(session, launch.llm) : this.acpSession(session, launch);
|
|
2264
2745
|
session.live = live;
|
|
2265
2746
|
const timeoutMs = this.options.startTimeoutMs ?? 6e4;
|
|
@@ -2418,7 +2899,7 @@ var SessionManager = class {
|
|
|
2418
2899
|
* files (CLAUDE.md, repo) to act on.
|
|
2419
2900
|
*/
|
|
2420
2901
|
allocateSandbox(sessionId) {
|
|
2421
|
-
const dir =
|
|
2902
|
+
const dir = join7(tmpdir4(), `leuria-${sessionId}`);
|
|
2422
2903
|
mkdirSync6(dir, { recursive: true });
|
|
2423
2904
|
return dir;
|
|
2424
2905
|
}
|
|
@@ -2509,6 +2990,7 @@ var WebMcpServer = class {
|
|
|
2509
2990
|
tools: /* @__PURE__ */ new Map(),
|
|
2510
2991
|
resources: /* @__PURE__ */ new Map(),
|
|
2511
2992
|
prompts: /* @__PURE__ */ new Map(),
|
|
2993
|
+
skills: [],
|
|
2512
2994
|
pendingRequests: /* @__PURE__ */ new Map(),
|
|
2513
2995
|
pingTimer: null
|
|
2514
2996
|
};
|
|
@@ -2617,20 +3099,33 @@ var WebMcpServer = class {
|
|
|
2617
3099
|
this.logger.info("WebMCP channel removed", { sessionId, channelId });
|
|
2618
3100
|
}
|
|
2619
3101
|
// ── In-process access (the engine's own agent loop for LLM providers) ──
|
|
2620
|
-
/**
|
|
3102
|
+
/** Give a session its site's skills: the agent then also gets `read_skill`. */
|
|
3103
|
+
setSkills(sessionId, skills) {
|
|
3104
|
+
const channelId = this.sessionChannels.get(sessionId);
|
|
3105
|
+
const channel = channelId ? this.channels.get(channelId) : void 0;
|
|
3106
|
+
if (channel) channel.skills = skills;
|
|
3107
|
+
}
|
|
3108
|
+
/** The tools a session's agent gets: the page's, and `read_skill` when the site has skills. */
|
|
2621
3109
|
listTools(sessionId) {
|
|
2622
3110
|
const channelId = this.sessionChannels.get(sessionId);
|
|
2623
3111
|
const channel = channelId ? this.channels.get(channelId) : void 0;
|
|
2624
|
-
return channel ?
|
|
3112
|
+
return channel ? this.toolsOf(channel) : [];
|
|
2625
3113
|
}
|
|
2626
|
-
/** Run a page tool in the browser; resolves with its JSON result. Throws on tool errors. */
|
|
3114
|
+
/** Run a page tool in the browser (or `read_skill` here); resolves with its JSON result. Throws on tool errors. */
|
|
2627
3115
|
async callTool(sessionId, name, args) {
|
|
2628
3116
|
const channelId = this.sessionChannels.get(sessionId);
|
|
2629
3117
|
const channel = channelId ? this.channels.get(channelId) : void 0;
|
|
2630
3118
|
if (!channel) throw new Error("The page is not connected");
|
|
3119
|
+
if (name === READ_SKILL && channel.skills.length) return readSkill(channel.skills, args);
|
|
2631
3120
|
if (!channel.tools.has(name)) throw new Error(`Unknown tool: ${name}`);
|
|
2632
3121
|
return this.forwardToBrowser(channel, "callTool", { tool: name, arguments: args });
|
|
2633
3122
|
}
|
|
3123
|
+
/** The page's tools, and the engine's `read_skill` in place of a page tool of that name. */
|
|
3124
|
+
toolsOf(channel) {
|
|
3125
|
+
const page2 = Array.from(channel.tools.values());
|
|
3126
|
+
if (!channel.skills.length) return page2;
|
|
3127
|
+
return [...page2.filter((t) => t.name !== READ_SKILL), readSkillTool(channel.skills)];
|
|
3128
|
+
}
|
|
2634
3129
|
// ── HTTP / WS dispatch (called from daemon) ──────────────────────────
|
|
2635
3130
|
/** Returns true when the request path belongs to WebMCP. */
|
|
2636
3131
|
matches(pathname) {
|
|
@@ -3036,7 +3531,7 @@ var WebMcpServer = class {
|
|
|
3036
3531
|
}
|
|
3037
3532
|
case "tools/list":
|
|
3038
3533
|
return {
|
|
3039
|
-
tools:
|
|
3534
|
+
tools: this.toolsOf(channel).map((t) => ({
|
|
3040
3535
|
name: t.name,
|
|
3041
3536
|
description: t.description,
|
|
3042
3537
|
inputSchema: t.inputSchema,
|
|
@@ -3047,6 +3542,9 @@ var WebMcpServer = class {
|
|
|
3047
3542
|
const name = req.params?.name;
|
|
3048
3543
|
const args = req.params?.arguments;
|
|
3049
3544
|
if (!name) throw new Error("Missing tool name");
|
|
3545
|
+
if (name === READ_SKILL && channel.skills.length) {
|
|
3546
|
+
return { content: [{ type: "text", text: readSkill(channel.skills, args ?? {}) }] };
|
|
3547
|
+
}
|
|
3050
3548
|
if (!channel.tools.has(name)) {
|
|
3051
3549
|
throw new Error(`Unknown tool: ${name}`);
|
|
3052
3550
|
}
|
|
@@ -3175,12 +3673,14 @@ async function startEngine(options) {
|
|
|
3175
3673
|
const selfOrigins = /* @__PURE__ */ new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
|
|
3176
3674
|
const allowedHosts = /* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
|
|
3177
3675
|
const silent = options.silent ?? Boolean(options.admin);
|
|
3676
|
+
const skills = options.skills ?? new SkillService({ grants, logger });
|
|
3178
3677
|
const webMcpServer = new WebMcpServer(logger, port);
|
|
3179
3678
|
const sessions = new SessionManager({
|
|
3180
3679
|
webMcpServer,
|
|
3181
3680
|
port,
|
|
3182
3681
|
logger,
|
|
3183
3682
|
resolveAgent: options.resolveAgent,
|
|
3683
|
+
loadSkills: (origin) => skills.forSession(origin),
|
|
3184
3684
|
startTimeoutMs: options.startTimeoutMs,
|
|
3185
3685
|
stdioMcpCommand: options.stdioMcpCommand,
|
|
3186
3686
|
onAgentState: options.onAgentState
|
|
@@ -3194,6 +3694,7 @@ async function startEngine(options) {
|
|
|
3194
3694
|
port,
|
|
3195
3695
|
linksOnly: silent,
|
|
3196
3696
|
agentName: () => agentName2(),
|
|
3697
|
+
resolveSkills: (origin, refs) => skills.resolve(origin, refs),
|
|
3197
3698
|
onRequest: options.onPairingRequest,
|
|
3198
3699
|
onDecided: options.onPairingDecided
|
|
3199
3700
|
});
|
|
@@ -3328,7 +3829,8 @@ async function startEngine(options) {
|
|
|
3328
3829
|
return;
|
|
3329
3830
|
}
|
|
3330
3831
|
const requester = caller.kind === "site" ? caller.origin : "local";
|
|
3331
|
-
|
|
3832
|
+
const onSkills = caller.kind === "site" ? (origin, refs) => skills.refresh(origin, refs) : void 0;
|
|
3833
|
+
if (await handleSessionRequest(req, res, pathname, sessions, requester, onSkills)) return;
|
|
3332
3834
|
sendJson(res, 404, { error: "Not found" });
|
|
3333
3835
|
} catch (err) {
|
|
3334
3836
|
logger.error("request failed", {
|
|
@@ -3407,6 +3909,8 @@ export {
|
|
|
3407
3909
|
decidePermission,
|
|
3408
3910
|
installedAgent,
|
|
3409
3911
|
ensureAgent,
|
|
3912
|
+
pruneAgentVersions,
|
|
3913
|
+
updateAgents,
|
|
3410
3914
|
resolveAgentCommand,
|
|
3411
3915
|
resolveSiteAgent,
|
|
3412
3916
|
agentName,
|