@ninemind/agentgem 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/gem/acpRecommender.js +19 -63
- package/dist/gem/acpRun.js +156 -0
- package/dist/gem/acpSession.js +79 -0
- package/dist/gem/analysisCache.js +6 -2
- package/dist/gem/archive.js +17 -0
- package/dist/gem/binPath.js +9 -0
- package/dist/gem/buildGem.js +4 -1
- package/dist/gem/channels.js +29 -0
- package/dist/gem/credentials.js +3 -2
- package/dist/gem/distill.js +162 -0
- package/dist/gem/draftStage.js +77 -0
- package/dist/gem/gemVerify.js +35 -0
- package/dist/gem/inputError.js +21 -0
- package/dist/gem/registry.js +23 -4
- package/dist/gem/runGem.js +161 -0
- package/dist/gem/safeFetch.js +112 -0
- package/dist/gem/sandbox.js +37 -0
- package/dist/gem/sandboxLaunch.js +55 -0
- package/dist/gem/scrub.js +108 -0
- package/dist/gem/search.js +34 -0
- package/dist/gem/share.js +21 -0
- package/dist/gem/targets.js +85 -39
- package/dist/gem/workflowScan.js +0 -0
- package/dist/gem/workspaces.js +4 -3
- package/dist/gem.controller.js +121 -17
- package/dist/gem.tools.js +53 -5
- package/dist/gemRunStream.js +67 -0
- package/dist/index.js +12 -2
- package/dist/originGuard.js +36 -0
- package/dist/public/index.html +261 -4
- package/dist/schemas.js +149 -8
- package/dist/workflowStream.js +10 -4
- package/package.json +6 -2
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/gem/sandboxLaunch.ts
|
|
2
|
+
// Pure generators for the OS-native sandbox launchers. The v1 boundary contains
|
|
3
|
+
// FILESYSTEM WRITES to the run dir (+ temp); reads, exec, and network stay open. This
|
|
4
|
+
// "write-deny" shape (allow-all, then deny writes, then re-allow under runDir) avoids
|
|
5
|
+
// the deny-default trap that kills the agent's own runtime before it can start.
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { realpathSync } from "node:fs";
|
|
8
|
+
// Resolve symlinks when the path exists; fall back to the original string otherwise.
|
|
9
|
+
function tryRealpath(p) {
|
|
10
|
+
try {
|
|
11
|
+
return realpathSync(p);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return p;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function seatbeltPolicy(runDir, tmpDir = tmpdir()) {
|
|
18
|
+
// Resolve symlinks so the SBPL subpath clause matches the kernel's canonical path.
|
|
19
|
+
// On macOS, tmpdir() returns /var/folders/... but the kernel sees /private/var/folders/...
|
|
20
|
+
// Fall back to the original path if the directory doesn't exist yet.
|
|
21
|
+
const realRun = tryRealpath(runDir);
|
|
22
|
+
const realTmp = tryRealpath(tmpDir);
|
|
23
|
+
return [
|
|
24
|
+
"(version 1)",
|
|
25
|
+
"(allow default)",
|
|
26
|
+
"(deny file-write*)",
|
|
27
|
+
"(allow file-write*",
|
|
28
|
+
` (subpath ${q(realRun)})`,
|
|
29
|
+
` (subpath ${q(realTmp)})`,
|
|
30
|
+
' (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr")',
|
|
31
|
+
' (subpath "/dev/tty") (regex #"^/dev/fd/"))',
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
// SBPL string literal: wrap in double quotes (paths under our control have no quotes).
|
|
35
|
+
function q(p) { return `"${p}"`; }
|
|
36
|
+
export function bwrapArgs(runDir, tmpDir = tmpdir()) {
|
|
37
|
+
// Resolve symlinks so the writable bind matches the kernel's canonical path
|
|
38
|
+
// (mirrors seatbeltPolicy); fall back to the original if the dir doesn't exist yet.
|
|
39
|
+
const realRun = tryRealpath(runDir);
|
|
40
|
+
const realTmp = tryRealpath(tmpDir);
|
|
41
|
+
return [
|
|
42
|
+
"--ro-bind", "/", "/", // everything readable, nothing writable…
|
|
43
|
+
"--bind", realRun, realRun, // …except the run dir…
|
|
44
|
+
"--bind", realTmp, realTmp, // …and temp.
|
|
45
|
+
"--dev", "/dev",
|
|
46
|
+
"--unshare-pid", // own PID namespace: the agent can't see/signal host processes
|
|
47
|
+
"--proc", "/proc", // fresh procfs for that namespace (must follow --unshare-pid)
|
|
48
|
+
"--die-with-parent",
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
export function wrapWithSandbox(kind, runDir, command) {
|
|
52
|
+
if (kind === "macos-seatbelt")
|
|
53
|
+
return ["sandbox-exec", "-p", seatbeltPolicy(runDir), ...command];
|
|
54
|
+
return ["bwrap", ...bwrapArgs(runDir), "--", ...command];
|
|
55
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// src/gem/scrub.ts
|
|
2
|
+
//
|
|
3
|
+
// Field-aware, default-deny scrubber for builtin tool_use inputs captured during
|
|
4
|
+
// transcript distillation (see docs/proposals/skill-distillation-from-transcripts.md
|
|
5
|
+
// §3a). Unlike redact.ts (which redacts secret VALUES in a structured config),
|
|
6
|
+
// this keeps only an allowlisted structural slice per builtin and DROPS everything
|
|
7
|
+
// else — removing the file-content/PII class by construction rather than blocklist.
|
|
8
|
+
//
|
|
9
|
+
// Output per step: { verb, arg } — a coarse low-cardinality `verb` for procedure
|
|
10
|
+
// recurrence (§3c) and a minimal scrubbed `arg` for the agent.
|
|
11
|
+
// Token-level secret detection. redact.ts redacts the WHOLE value and can lean on
|
|
12
|
+
// bare keywords because it has key/value structure; here we scrub free-text command
|
|
13
|
+
// tokens, where bare words like "secret"/"token"/"password" legitimately appear in
|
|
14
|
+
// filenames and commit messages (`cat secret.env`). So the rule is intentionally
|
|
15
|
+
// narrower: a token is secret only if it is HIGH-ENTROPY or carries a known secret
|
|
16
|
+
// PREFIX — never a plain dictionary keyword. (Short keyword-less secrets survive;
|
|
17
|
+
// that residual risk is accepted under the draft-only review gate — proposal §3a.)
|
|
18
|
+
const SECRET_PREFIX_RE = /^(sk-|ghp_|gho_|ghu_|ghs_|github_pat_|xox[a-z]-|AKIA|ASIA|glpat-)/;
|
|
19
|
+
function looksLikeSecretToken(t) {
|
|
20
|
+
if (t.length >= 32 && /^[A-Za-z0-9_-]+$/.test(t))
|
|
21
|
+
return true; // high entropy
|
|
22
|
+
if (t.length >= 8 && SECRET_PREFIX_RE.test(t))
|
|
23
|
+
return true; // prefixed token
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
// Rewrite $HOME-absolute prefixes to ~ and any /Users/<name>/ to ~/, so paths
|
|
27
|
+
// never carry a username. Applied before token scrub.
|
|
28
|
+
function dehomePaths(s) {
|
|
29
|
+
const home = process.env.HOME;
|
|
30
|
+
let out = home ? s.split(home).join("~") : s;
|
|
31
|
+
out = out.replace(/\/Users\/[^/\s]+\//g, "~/");
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
// Token-scrub a free-text arg: de-home paths, then replace any secret-looking
|
|
35
|
+
// whitespace token with <redacted>, leaving the rest of the command intact.
|
|
36
|
+
function scrubText(s) {
|
|
37
|
+
return dehomePaths(s)
|
|
38
|
+
.split(/(\s+)/) // keep separators so spacing is preserved
|
|
39
|
+
.map((tok) => (/\s/.test(tok) ? tok : redactToken(tok)))
|
|
40
|
+
.join("");
|
|
41
|
+
}
|
|
42
|
+
// A token may carry a secret embedded in surrounding syntax (https://SECRET@host).
|
|
43
|
+
// Redact the embedded secret, not the whole token, so structure survives.
|
|
44
|
+
function redactToken(tok) {
|
|
45
|
+
if (!tok)
|
|
46
|
+
return tok;
|
|
47
|
+
return tok
|
|
48
|
+
.split(/([^A-Za-z0-9_-]+)/) // split on non-identifier runs, keep them
|
|
49
|
+
.map((part) => (/^[A-Za-z0-9_-]+$/.test(part) && looksLikeSecretToken(part) ? "<redacted>" : part))
|
|
50
|
+
.join("");
|
|
51
|
+
}
|
|
52
|
+
// Scrub free-text prose (mission-hint task/outcome). Same token scrub + de-home as
|
|
53
|
+
// command args, plus a hard length cap — this is the one place free text is kept,
|
|
54
|
+
// so it is deliberately short and low-detail (proposal §3b).
|
|
55
|
+
export function scrubProse(s, maxLen = 280) {
|
|
56
|
+
const scrubbed = scrubText(s).trim();
|
|
57
|
+
if (scrubbed.length <= maxLen)
|
|
58
|
+
return scrubbed;
|
|
59
|
+
return scrubbed.slice(0, maxLen) + "…";
|
|
60
|
+
}
|
|
61
|
+
// Coarse procedure verb: "git commit -m fix" -> "Bash:git commit", "cd /x" ->
|
|
62
|
+
// "Bash:cd", "/usr/bin/npx vitest" -> "Bash:npx vitest". argv0 is basenamed; the
|
|
63
|
+
// 2nd token counts as a subcommand ONLY if it's a clean lowercase word — a path,
|
|
64
|
+
// filename, flag, or quoted arg is NOT a subcommand, so it never inflates the verb.
|
|
65
|
+
function bashVerb(command) {
|
|
66
|
+
const toks = command.trim().split(/\s+/).filter(Boolean);
|
|
67
|
+
if (!toks.length)
|
|
68
|
+
return "Bash";
|
|
69
|
+
const argv0 = (toks[0].split("/").pop() || toks[0]).replace(/[;|&]+$/, "");
|
|
70
|
+
if (!argv0)
|
|
71
|
+
return "Bash";
|
|
72
|
+
const sub = toks[1] && /^[a-z][a-z0-9-]*$/.test(toks[1]) ? ` ${toks[1]}` : "";
|
|
73
|
+
return `Bash:${argv0}${sub}`;
|
|
74
|
+
}
|
|
75
|
+
function str(input, key) {
|
|
76
|
+
const v = input?.[key];
|
|
77
|
+
return typeof v === "string" ? v : "";
|
|
78
|
+
}
|
|
79
|
+
// Default-deny: each builtin keeps only an allowlisted structural slice; every
|
|
80
|
+
// other field (file contents, agent prompts, tool output, unknown fields) is
|
|
81
|
+
// dropped, not scrubbed — so the file-content/PII class is removed by construction.
|
|
82
|
+
export function scrubStep(tool, input) {
|
|
83
|
+
switch (tool) {
|
|
84
|
+
case "Bash": {
|
|
85
|
+
const command = str(input, "command");
|
|
86
|
+
return { verb: bashVerb(command), arg: scrubText(command) };
|
|
87
|
+
}
|
|
88
|
+
// Edit/Write/NotebookEdit: keep the path; DROP old_string/new_string/content.
|
|
89
|
+
case "Edit":
|
|
90
|
+
case "Write":
|
|
91
|
+
case "NotebookEdit":
|
|
92
|
+
return { verb: tool, arg: scrubText(str(input, "file_path") || str(input, "notebook_path")) };
|
|
93
|
+
// Read/Grep/Glob: keep the path/pattern; DROP file contents and match output.
|
|
94
|
+
case "Read":
|
|
95
|
+
case "Grep":
|
|
96
|
+
case "Glob":
|
|
97
|
+
return { verb: tool, arg: scrubText(str(input, "file_path") || str(input, "path") || str(input, "pattern")) };
|
|
98
|
+
// Task/agent spawns: keep the short description; DROP the prompt.
|
|
99
|
+
case "Task":
|
|
100
|
+
case "Agent": {
|
|
101
|
+
const sub = str(input, "subagent_type");
|
|
102
|
+
return { verb: sub ? `${tool}:${sub}` : tool, arg: scrubText(str(input, "description")) };
|
|
103
|
+
}
|
|
104
|
+
// Unknown tool: verb only, entire input dropped.
|
|
105
|
+
default:
|
|
106
|
+
return { verb: tool, arg: "" };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Weighted field match: name >> tags > description. An empty query (with optional
|
|
2
|
+
// kind/tag filter) browses the catalog — every gem returns, score 0.
|
|
3
|
+
export function searchIndex(index, query, opts = {}) {
|
|
4
|
+
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
5
|
+
const hits = [];
|
|
6
|
+
for (const [key, item] of Object.entries(index.items)) {
|
|
7
|
+
const d = item.discovery ?? {};
|
|
8
|
+
if (opts.kind && !(d.artifactKinds ?? []).includes(opts.kind))
|
|
9
|
+
continue;
|
|
10
|
+
if (opts.tag && !(d.tags ?? []).includes(opts.tag))
|
|
11
|
+
continue;
|
|
12
|
+
const name = key.toLowerCase();
|
|
13
|
+
const tags = (d.tags ?? []).join(" ").toLowerCase();
|
|
14
|
+
const desc = (d.description ?? "").toLowerCase();
|
|
15
|
+
let score = 0;
|
|
16
|
+
for (const t of terms) {
|
|
17
|
+
if (name === t)
|
|
18
|
+
score += 100;
|
|
19
|
+
else if (name.includes(t))
|
|
20
|
+
score += 10;
|
|
21
|
+
if (tags.split(/\s+/).includes(t))
|
|
22
|
+
score += 5;
|
|
23
|
+
else if (tags.includes(t))
|
|
24
|
+
score += 3;
|
|
25
|
+
if (desc.includes(t))
|
|
26
|
+
score += 1;
|
|
27
|
+
}
|
|
28
|
+
if (terms.length && score === 0)
|
|
29
|
+
continue; // query given but nothing matched
|
|
30
|
+
hits.push({ key, latest: item.latest, score, description: d.description, tags: d.tags, author: d.author, artifactKinds: d.artifactKinds, updatedAt: d.updatedAt });
|
|
31
|
+
}
|
|
32
|
+
hits.sort((a, b) => b.score - a.score || a.key.localeCompare(b.key));
|
|
33
|
+
return hits.slice(0, opts.limit ?? 25);
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/gem/share.ts
|
|
2
|
+
// The registry-optional "easy share" loop: turn a Gem into one portable .gem file
|
|
3
|
+
// and install one back. Pure + in-process — no disk/network — so it composes with
|
|
4
|
+
// any transport (file, URL, gist, paste). Integrity is inherited from readGemArchive,
|
|
5
|
+
// which verifies gem.lock and throws on any mismatch, so a tampered .gem never installs.
|
|
6
|
+
import { writeGemArchive, readGemArchive, readGemMeta } from "./archive.js";
|
|
7
|
+
import { packTar, unpackTar } from "./archiveTar.js";
|
|
8
|
+
import { safePathSegment } from "./targets.js";
|
|
9
|
+
// Gem -> a single self-verifying .gem (gzipped tar of the archive file tree).
|
|
10
|
+
export function exportGem(gem, opts = {}) {
|
|
11
|
+
const { files, skipped } = writeGemArchive(gem, opts);
|
|
12
|
+
const version = opts.version ?? "0.1.0";
|
|
13
|
+
return { filename: `${safePathSegment(gem.name)}-${version}.gem`, bytes: packTar(files), skipped };
|
|
14
|
+
}
|
|
15
|
+
// A .gem's bytes -> the verified Gem. Throws if the bytes aren't a valid archive
|
|
16
|
+
// or if gem.lock verification fails (tampering / corruption).
|
|
17
|
+
export function importGem(bytes) {
|
|
18
|
+
const files = unpackTar(bytes);
|
|
19
|
+
const gem = readGemArchive(files); // verifies gem.lock; throws on mismatch
|
|
20
|
+
return { gem, meta: readGemMeta(files) };
|
|
21
|
+
}
|
package/dist/gem/targets.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { channelScaffold } from "./channels.js";
|
|
1
2
|
import { tomlMcpServers } from "./toml.js";
|
|
2
3
|
import { stdioProxyRunner, PROXY_BASE_PORT, PROXY_HOST } from "./mcpProxy.js";
|
|
3
4
|
export function safePathSegment(name) {
|
|
@@ -19,6 +20,17 @@ const fluePascal = (name) => flueName(name).split("-").filter(Boolean).map((w) =
|
|
|
19
20
|
// Flue skills live under src/ (the agent file is src/agents/<name>.ts and imports ../skills/...).
|
|
20
21
|
const skillFlueMd = (a) => ({ [`src/skills/${safePathSegment(a.name)}/SKILL.md`]: a.content });
|
|
21
22
|
const rendered = (files) => ({ files, skipped: [] });
|
|
23
|
+
// MCP header secrets -> [headerName, envVarName] entries, Authorization first. Shared by the HTTP/SSE
|
|
24
|
+
// renderers (flue / openai-sandbox / a2a); the env-var NAME is emitted, never a value. Callers that
|
|
25
|
+
// require header-only auth check separately for a non-`headers.` secret (the unsupported case).
|
|
26
|
+
const headerSecretEntries = (refs) => {
|
|
27
|
+
const authorization = refs.find((r) => r.location.toLowerCase() === "headers.authorization");
|
|
28
|
+
return [
|
|
29
|
+
...(authorization ? [["Authorization", authorization.name]] : []),
|
|
30
|
+
...refs.filter((r) => /^headers\./i.test(r.location) && r !== authorization)
|
|
31
|
+
.map((r) => [r.location.slice("headers.".length), r.name]),
|
|
32
|
+
];
|
|
33
|
+
};
|
|
22
34
|
// ── shared convention renderers ──
|
|
23
35
|
const skillSkillMd = (a) => ({ [`skills/${safePathSegment(a.name)}/SKILL.md`]: a.content });
|
|
24
36
|
const skillDescriptionMd = (a) => ({ [`skills/${safePathSegment(a.name)}/DESCRIPTION.md`]: a.content });
|
|
@@ -220,13 +232,7 @@ function escapeTemplate(s) {
|
|
|
220
232
|
// env var name, never a value). stdio -> a localhost connection plus a generated proxy runner under
|
|
221
233
|
// proxies/ that bridges the stdio server to HTTP (same mechanism as Eve).
|
|
222
234
|
const flueConnection = (server, url) => {
|
|
223
|
-
const
|
|
224
|
-
const authorization = refs.find((r) => r.location.toLowerCase() === "headers.authorization");
|
|
225
|
-
const headerEntries = [
|
|
226
|
-
...(authorization ? [["Authorization", authorization.name]] : []),
|
|
227
|
-
...refs.filter((r) => /^headers\./i.test(r.location) && r !== authorization)
|
|
228
|
-
.map((r) => [r.location.slice("headers.".length), r.name]),
|
|
229
|
-
];
|
|
235
|
+
const headerEntries = headerSecretEntries(server.secretRefs ?? []);
|
|
230
236
|
const transport = server.transport === "sse" ? `,\n transport: "sse"` : "";
|
|
231
237
|
const headers = headerEntries.length
|
|
232
238
|
? `,\n headers: { ${headerEntries.map(([h, env]) => `${JSON.stringify(h)}: process.env[${JSON.stringify(env)}]!`).join(", ")} }`
|
|
@@ -339,11 +345,7 @@ const sandboxMcpServer = (s) => {
|
|
|
339
345
|
const unsupported = refs.find((r) => !/^headers\./i.test(r.location));
|
|
340
346
|
if (unsupported)
|
|
341
347
|
return { skip: `OpenAI sandbox cannot map secret at ${unsupported.location}` };
|
|
342
|
-
const
|
|
343
|
-
const headerEntries = [
|
|
344
|
-
...(authorization ? [["Authorization", authorization.name]] : []),
|
|
345
|
-
...refs.filter((r) => /^headers\./i.test(r.location) && r !== authorization).map((r) => [r.location.slice("headers.".length), r.name]),
|
|
346
|
-
];
|
|
348
|
+
const headerEntries = headerSecretEntries(refs);
|
|
347
349
|
const requestInit = headerEntries.length
|
|
348
350
|
? `, requestInit: { headers: { ${headerEntries.map(([h, e]) => `${JSON.stringify(h)}: process.env[${JSON.stringify(e)}]!`).join(", ")} } }`
|
|
349
351
|
: "";
|
|
@@ -475,10 +477,10 @@ const evePackageJson = (gemName) => JSON.stringify({
|
|
|
475
477
|
type: "module",
|
|
476
478
|
imports: { "#*": "./agent/*", "#evals/*": "./evals/*" },
|
|
477
479
|
scripts: { build: "eve build", dev: "eve dev", start: "eve start", typecheck: "tsgo" },
|
|
478
|
-
dependencies: { "@vercel/connect": "0.2.2", ai: "7.0.
|
|
480
|
+
dependencies: { "@vercel/connect": "0.2.2", ai: "7.0.2", eve: "^0.15.0", microsandbox: "^0.5.0", zod: "4.4.3" },
|
|
479
481
|
devDependencies: { "@types/node": "24.x", "@typescript/native-preview": "7.0.0-dev.20260523.1" },
|
|
480
|
-
overrides: { ai: "7.0.
|
|
481
|
-
resolutions: { ai: "7.0.
|
|
482
|
+
overrides: { ai: "7.0.2" },
|
|
483
|
+
resolutions: { ai: "7.0.2" },
|
|
482
484
|
engines: { node: "24.x" },
|
|
483
485
|
}, null, 2) + "\n";
|
|
484
486
|
// Cross-cutting scaffold: the files `eve init` provides so the rendered agent/ source is runnable.
|
|
@@ -499,6 +501,26 @@ const eveComposeProject = (gem, opts = {}) => {
|
|
|
499
501
|
}
|
|
500
502
|
return rendered(files);
|
|
501
503
|
};
|
|
504
|
+
// Eve channel files: one agent/channels/<name>.ts per declared channel, from the platform registry
|
|
505
|
+
// scaffold. "eve" is reserved for the always-on web/auth channel that eveComposeProject emits.
|
|
506
|
+
const channelEve = (channels) => {
|
|
507
|
+
const files = {};
|
|
508
|
+
const skipped = [];
|
|
509
|
+
for (const c of channels) {
|
|
510
|
+
const seg = eveSegment(c.name);
|
|
511
|
+
const path = `agent/channels/${seg}.ts`;
|
|
512
|
+
if (seg === "eve") {
|
|
513
|
+
skipped.push({ artifact: c.name, type: "channel", reason: "channel name 'eve' is reserved for the web channel" });
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (path in files) {
|
|
517
|
+
skipped.push({ artifact: c.name, type: "channel", reason: `path collision with an earlier channel at ${path}` });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
files[path] = channelScaffold(c.platform);
|
|
521
|
+
}
|
|
522
|
+
return { files, skipped };
|
|
523
|
+
};
|
|
502
524
|
// ── A2A (Agent2Agent) target ──
|
|
503
525
|
// Card primitive: materialize(gem, "a2a") emits a runtime-free Agent Card derived from the gem — the
|
|
504
526
|
// A2A discovery surface, publishable to the registry. The Card is the part native to AgentGem's
|
|
@@ -512,10 +534,13 @@ const a2aSkillCard = (a) => ({
|
|
|
512
534
|
});
|
|
513
535
|
// A one-line card description from an instruction artifact: prefer the first non-empty *prose* line
|
|
514
536
|
// (instruction files usually open with a throwaway "# Title" heading); fall back to the de-headed
|
|
515
|
-
// first line if the doc is headings-only.
|
|
537
|
+
// first line if the doc is headings-only. Only ATX headings ("# " … "###### ") count as headings, so a
|
|
538
|
+
// prose line that merely starts with '#' (e.g. "#launch") is kept. Bounded so the card carries a label,
|
|
539
|
+
// not a paragraph.
|
|
516
540
|
const a2aFirstLine = (s) => {
|
|
517
541
|
const lines = s.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
518
|
-
|
|
542
|
+
const line = lines.find((l) => !/^#{1,6}\s/.test(l)) ?? lines[0]?.replace(/^#+\s*/, "") ?? "";
|
|
543
|
+
return line.length > 200 ? line.slice(0, 197).replace(/\s+\S*$/, "") + "…" : line;
|
|
519
544
|
};
|
|
520
545
|
// Pure Gem -> AgentCard projection. Skills advertise as A2A skills (metadata, not bodies); the first
|
|
521
546
|
// instruction line becomes the card description; a skill-less Gem gets a synthesized `chat` skill
|
|
@@ -529,7 +554,9 @@ export const a2aAgentCard = (gem) => {
|
|
|
529
554
|
name: gem.name,
|
|
530
555
|
description: a2aFirstLine(instr[0]?.content ?? "") || `An agent packaged by AgentGem from ${skills.length} skill(s).`,
|
|
531
556
|
version: "0.1.0",
|
|
532
|
-
|
|
557
|
+
// Non-resolving placeholder (RFC 6761 reserved TLD): a published card must NOT carry a localhost url
|
|
558
|
+
// a consumer would dial against its own machine. Server mode rebinds this from PUBLIC_URL at boot.
|
|
559
|
+
url: "https://set-public-url.invalid/a2a/jsonrpc",
|
|
533
560
|
capabilities: { streaming: false, pushNotifications: false },
|
|
534
561
|
defaultInputModes: ["text"],
|
|
535
562
|
defaultOutputModes: ["text"],
|
|
@@ -548,12 +575,7 @@ const a2aMcpClient = (s) => {
|
|
|
548
575
|
const unsupported = refs.find((r) => !/^headers\./i.test(r.location));
|
|
549
576
|
if (unsupported)
|
|
550
577
|
return { skip: `A2A (AI SDK) cannot map secret at ${unsupported.location}` };
|
|
551
|
-
const
|
|
552
|
-
const headerEntries = [
|
|
553
|
-
...(authorization ? [["Authorization", authorization.name]] : []),
|
|
554
|
-
...refs.filter((r) => /^headers\./i.test(r.location) && r !== authorization)
|
|
555
|
-
.map((r) => [r.location.slice("headers.".length), r.name]),
|
|
556
|
-
];
|
|
578
|
+
const headerEntries = headerSecretEntries(refs);
|
|
557
579
|
const headers = headerEntries.length
|
|
558
580
|
? `, headers: { ${headerEntries.map(([h, e]) => `${JSON.stringify(h)}: process.env[${JSON.stringify(e)}]!`).join(", ")} }`
|
|
559
581
|
: "";
|
|
@@ -584,8 +606,8 @@ const a2aSecretsMd = (secrets) => {
|
|
|
584
606
|
const a2aPackageJson = (gemName) => JSON.stringify({
|
|
585
607
|
name: safePathSegment(gemName).toLowerCase(), version: "0.1.0", private: true, type: "module",
|
|
586
608
|
scripts: { build: "tsc", start: "node dist/server.js", dev: "tsx src/server.ts" },
|
|
587
|
-
// Verified pins: ai v7
|
|
588
|
-
dependencies: { "@a2a-js/sdk": "^0.3.13", ai: "7.0.
|
|
609
|
+
// Verified pins: ai v7 GA pairs with @ai-sdk/mcp v2 GA (both on @ai-sdk/provider@4); @a2a-js/sdk 0.3.x.
|
|
610
|
+
dependencies: { "@a2a-js/sdk": "^0.3.13", ai: "7.0.2", "@ai-sdk/mcp": "2.0.0", express: "^5", uuid: "^11" },
|
|
589
611
|
devDependencies: { "@types/express": "^5", "@types/node": "^24", tsx: "^4", typescript: "^5" },
|
|
590
612
|
}, null, 2) + "\n";
|
|
591
613
|
// The runnable A2A server: an AI SDK `streamText` tool loop behind the @a2a-js/sdk JSON-RPC handler.
|
|
@@ -632,7 +654,15 @@ class GemExecutor implements AgentExecutor {
|
|
|
632
654
|
private inflight = new Map<string, AbortController>();
|
|
633
655
|
async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
|
|
634
656
|
const { taskId, contextId, userMessage, task } = ctx;
|
|
635
|
-
const text = (userMessage.parts ?? []).filter((p: any) => p.kind === "text").map((p: any) => p.text).join("\\n");
|
|
657
|
+
const text = (userMessage.parts ?? []).filter((p: any) => p.kind === "text").map((p: any) => p.text).join("\\n").trim();
|
|
658
|
+
// Guard: an A2A message may carry no text parts (file/data only). streamText rejects an empty
|
|
659
|
+
// prompt, so reply directly instead of failing the request.
|
|
660
|
+
if (!text) {
|
|
661
|
+
bus.publish({ kind: "message", messageId: uuid(), role: "agent", contextId,
|
|
662
|
+
parts: [{ kind: "text", text: "Please include a text message for the agent." }] });
|
|
663
|
+
bus.finished();
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
636
666
|
const ac = new AbortController();
|
|
637
667
|
this.inflight.set(taskId, ac);
|
|
638
668
|
if (!task) bus.publish({ kind: "task", id: taskId, contextId, status: { state: "submitted", timestamp: new Date().toISOString() }, history: [userMessage] });
|
|
@@ -646,7 +676,8 @@ class GemExecutor implements AgentExecutor {
|
|
|
646
676
|
artifact: { artifactId, name: "response", parts: [{ kind: "text", text: delta }] } });
|
|
647
677
|
started = true;
|
|
648
678
|
}
|
|
649
|
-
|
|
679
|
+
// Only close an artifact that was actually opened (empty/tool-only completions stream nothing).
|
|
680
|
+
if (started) bus.publish({ kind: "artifact-update", taskId, contextId, append: true, lastChunk: true, artifact: { artifactId, parts: [] } });
|
|
650
681
|
bus.publish({ kind: "status-update", taskId, contextId, status: { state: "completed", timestamp: new Date().toISOString() }, final: true });
|
|
651
682
|
} catch (err) {
|
|
652
683
|
const state = ac.signal.aborted ? "canceled" : "failed";
|
|
@@ -675,22 +706,29 @@ app.use("/a2a/rest", restHandler({ requestHandler, userBuilder: UserBuilder.noAu
|
|
|
675
706
|
app.listen(port, () => console.log(\`A2A agent "\${card.name}" listening on :\${port}\`));
|
|
676
707
|
`;
|
|
677
708
|
};
|
|
678
|
-
// A2A is wholly compose-driven: per-type renderers are no-ops (so
|
|
679
|
-
// compose
|
|
680
|
-
//
|
|
709
|
+
// A2A is wholly compose-driven: per-type renderers are no-ops (so materialize never auto-skip-reports),
|
|
710
|
+
// and compose owns ALL skip reporting for both modes. Hooks are never expressible by A2A (card or
|
|
711
|
+
// server). MCP is not expressible by a *Card* (card-only -> all MCP skipped), but the *server* wires it
|
|
712
|
+
// (server mode -> only unmappable MCP skipped). This keeps compatibility() honest: card-only reflects
|
|
713
|
+
// that a Card carries identity + skills, not MCP/hooks, instead of over-claiming full support.
|
|
681
714
|
const a2aComposeProject = (gem, opts = {}) => {
|
|
682
715
|
const files = { "agent-card.json": JSON.stringify(a2aAgentCard(gem), null, 2) + "\n" };
|
|
683
|
-
if (!opts.a2aServer)
|
|
684
|
-
return { files, skipped: [] };
|
|
685
|
-
const skills = gem.artifacts.filter((a) => a.type === "skill");
|
|
686
|
-
const instr = gem.artifacts.filter((a) => a.type === "instructions");
|
|
687
716
|
const mcps = gem.artifacts.filter((a) => a.type === "mcp_server");
|
|
688
717
|
const hooks = gem.artifacts.filter((a) => a.type === "hook");
|
|
718
|
+
const hookSkips = hooks.map((h) => ({ artifact: h.name, type: "hook", reason: "A2A has no hook concept" }));
|
|
719
|
+
if (!opts.a2aServer) {
|
|
720
|
+
// Card-only: an Agent Card represents identity + skills, not MCP servers or hooks.
|
|
721
|
+
const cardSkips = mcps.map((s) => ({ artifact: s.name, type: "mcp_server", reason: "an Agent Card cannot express MCP servers (materialize with a2aServer to wire them)" }));
|
|
722
|
+
return { files, skipped: [...cardSkips, ...hookSkips] };
|
|
723
|
+
}
|
|
724
|
+
const skills = gem.artifacts.filter((a) => a.type === "skill");
|
|
725
|
+
const instr = gem.artifacts.filter((a) => a.type === "instructions");
|
|
689
726
|
// AI SDK has no skills primitive -> fold skill bodies (frontmatter-stripped) into the system prompt.
|
|
690
727
|
const instrText = instr.map((i) => `## ${i.name}\n\n${i.content}`).join("\n\n---\n\n");
|
|
691
728
|
const skillText = skills.map((s) => `## Skill: ${s.name}\n\n${stripYamlFrontmatter(s.content)}`).join("\n\n---\n\n");
|
|
692
729
|
const system = [instrText, skillText].filter(Boolean).join("\n\n---\n\n");
|
|
693
|
-
|
|
730
|
+
// Server mode: the server wires MCP, so only UNMAPPABLE MCP is skipped; hooks remain unsupported.
|
|
731
|
+
const skipped = [...hookSkips];
|
|
694
732
|
const clientCodes = [];
|
|
695
733
|
let usesStdio = false;
|
|
696
734
|
for (const s of mcps) {
|
|
@@ -702,8 +740,6 @@ const a2aComposeProject = (gem, opts = {}) => {
|
|
|
702
740
|
clientCodes.push(r.code);
|
|
703
741
|
usesStdio ||= r.stdio;
|
|
704
742
|
}
|
|
705
|
-
for (const h of hooks)
|
|
706
|
-
skipped.push({ artifact: h.name, type: "hook", reason: "A2A has no hook concept" });
|
|
707
743
|
return {
|
|
708
744
|
files: {
|
|
709
745
|
...files,
|
|
@@ -721,7 +757,7 @@ export const TARGET_REGISTRY = {
|
|
|
721
757
|
agents: { id: "agents", label: "Agents", skill: skillSkillMd, instructions: instructionsAgentsMd },
|
|
722
758
|
hermes: { id: "hermes", label: "Hermes", skill: skillDescriptionMd, instructions: instructionsSoulMd },
|
|
723
759
|
// Eve project layout (agent/...). Hooks are event-reacting code in Eve, not config -> unsupported.
|
|
724
|
-
eve: { id: "eve", label: "Eve", skill: skillEveMd, instructions: concatInstructions("agent/instructions.md"), mcp: mcpEveConnections, compose: eveComposeProject },
|
|
760
|
+
eve: { id: "eve", label: "Eve", skill: skillEveMd, instructions: concatInstructions("agent/instructions.md"), mcp: mcpEveConnections, channel: channelEve, compose: eveComposeProject },
|
|
725
761
|
// Flue project layout. Skills reuse SKILL.md; instructions fold into the composed agent file (no
|
|
726
762
|
// standalone file -> the empty instructions renderer marks them handled, not skipped). MCP added in Task 2.
|
|
727
763
|
flue: { id: "flue", label: "Flue", skill: skillFlueMd, instructions: () => ({}), mcp: mcpFlueConnections, compose: flueComposeAgent },
|
|
@@ -753,6 +789,7 @@ export function materialize(gem, target, opts = {}) {
|
|
|
753
789
|
const mcp = gem.artifacts.filter((a) => a.type === "mcp_server");
|
|
754
790
|
const instr = gem.artifacts.filter((a) => a.type === "instructions");
|
|
755
791
|
const hooks = gem.artifacts.filter((a) => a.type === "hook");
|
|
792
|
+
const channels = gem.artifacts.filter((a) => a.type === "channel");
|
|
756
793
|
if (spec.skill)
|
|
757
794
|
for (const s of skills)
|
|
758
795
|
merge(spec.skill(s), s.name, "skill");
|
|
@@ -779,6 +816,15 @@ export function materialize(gem, target, opts = {}) {
|
|
|
779
816
|
else
|
|
780
817
|
skipAll(hooks, "hook");
|
|
781
818
|
}
|
|
819
|
+
if (channels.length) {
|
|
820
|
+
if (spec.channel) {
|
|
821
|
+
const result = spec.channel(channels);
|
|
822
|
+
merge(result.files, channels.map((c) => c.name).join(", "), "channel");
|
|
823
|
+
skipped.push(...result.skipped);
|
|
824
|
+
}
|
|
825
|
+
else
|
|
826
|
+
skipAll(channels, "channel");
|
|
827
|
+
}
|
|
782
828
|
if (spec.compose) {
|
|
783
829
|
const result = spec.compose(gem, opts);
|
|
784
830
|
merge(result.files, "(composed agent)", "instructions"); // collisions reported; agent file derives from instructions+skills
|
package/dist/gem/workflowScan.js
CHANGED
|
Binary file
|
package/dist/gem/workspaces.js
CHANGED
|
@@ -8,6 +8,7 @@ import { mkdirSync, rmSync, readdirSync, statSync, existsSync, readFileSync } fr
|
|
|
8
8
|
import { materialize, compatibility, TARGET_REGISTRY, safePathSegment } from "./targets.js";
|
|
9
9
|
import { writeGemArchive, readGemArchive } from "./archive.js";
|
|
10
10
|
import { writeArchiveDir, readArchiveDir } from "./archiveFs.js";
|
|
11
|
+
import { InvalidInputError } from "./inputError.js";
|
|
11
12
|
const TARGETS_DIR = ".targets";
|
|
12
13
|
export function workspacesRoot() {
|
|
13
14
|
const home = process.env.AGENTGEM_HOME ?? join(homedir(), ".agentgem");
|
|
@@ -18,7 +19,7 @@ export function workspacesRoot() {
|
|
|
18
19
|
export function workspaceName(name) {
|
|
19
20
|
const seg = safePathSegment(name);
|
|
20
21
|
if (seg !== name)
|
|
21
|
-
throw new
|
|
22
|
+
throw new InvalidInputError(`invalid workspace name '${name}' — use only [A-Za-z0-9._-], no separators`);
|
|
22
23
|
return seg;
|
|
23
24
|
}
|
|
24
25
|
export function workspaceDir(name) {
|
|
@@ -73,12 +74,12 @@ export function readWorkspace(name) {
|
|
|
73
74
|
const gem = readGemArchive(files); // verifies the lock
|
|
74
75
|
return { ...summary(workspaceName(name), files["gem.json"], dir), files, compatibility: compatibility(gem) };
|
|
75
76
|
}
|
|
76
|
-
export function renderTarget(name, target) {
|
|
77
|
+
export function renderTarget(name, target, opts = {}) {
|
|
77
78
|
const dir = workspaceDir(name);
|
|
78
79
|
if (!existsSync(join(dir, "gem.json")))
|
|
79
80
|
throw new Error(`no workspace '${name}'`);
|
|
80
81
|
const gem = readGemArchive(readArchiveDir(dir));
|
|
81
|
-
const { files, skipped } = materialize(gem, target);
|
|
82
|
+
const { files, skipped } = materialize(gem, target, opts);
|
|
82
83
|
const out = join(dir, TARGETS_DIR, target);
|
|
83
84
|
rmSync(out, { recursive: true, force: true }); // clear stale renders
|
|
84
85
|
mkdirSync(out, { recursive: true });
|