@ninemind/agentgem 0.1.1 → 0.2.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 CHANGED
@@ -37,9 +37,16 @@ call exactly the same thing.
37
37
  re-reading raw config.
38
38
  - **Composition** — the manifest/lock split lets small, focused Gems be reconciled into
39
39
  larger agents with a single re-resolved lock, not a pile of overlapping config.
40
+ - **Workflow-aware recommendations** — [Analyze](docs/analyze.md) scans your agent's
41
+ session history to see which skills, MCP servers, and hooks you actually use, and
42
+ suggests ready-to-build Gems grouped by recurring workflow.
40
43
  - **Deploy targets** — Eve and OpenAI Sandbox (code-gen), Flue (materialize, deployable to
41
44
  Cloudflare), and Bedrock AgentCore (managed backend); code-gen targets share a common
42
45
  `compose` step.
46
+ - **Agent-to-agent (A2A)** — export a Gem as an [A2A](docs/a2a.md) Agent Card or a
47
+ runnable A2A server so other agents can discover and call it.
48
+ - **A native desktop app** — a [macOS/Windows/Linux build](docs/desktop.md) alongside the
49
+ `npx` CLI, hosting the same local server in its own window.
43
50
  - **A GitHub-backed registry** — publish, resolve, merge, and install composable Gems over
44
51
  the same archive format.
45
52
  - **An agent-native path** — every operation is also an MCP tool, so your local agent can
@@ -106,6 +113,20 @@ pnpm clean # or: npm run clean — rm -rf dist *.tsbuildinfo (run before r
106
113
 
107
114
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow.
108
115
 
116
+ ### Desktop app
117
+
118
+ Prefer a double-click app over the CLI? AgentGem ships a native **desktop build**
119
+ for macOS, Windows, and Linux — download it from
120
+ [Releases](https://github.com/ninemindai/agentgem/releases) (a `desktop-v*` build).
121
+ It hosts the same local server in its own window, adds a native folder picker, app
122
+ menu, and system tray, and never sends secrets off your machine.
123
+
124
+ > The builds are currently **unsigned**: on macOS right-click → **Open**, on Windows
125
+ > choose **More info → Run anyway** the first time.
126
+
127
+ To run or package it from source, see the [desktop guide](docs/desktop.md) — in
128
+ short, `pnpm -C desktop dev` to run, `pnpm -C desktop dist` to build installers.
129
+
109
130
  ## Layering
110
131
 
111
132
  Depends on AgentBack: `@agentback/core` (lifecycle), `@agentback/rest` +
@@ -116,8 +137,11 @@ API, and the MCP endpoint are three boundaries over one set of Zod contracts —
116
137
 
117
138
  For deeper reference, see [`docs/`](docs/index.md):
118
139
  [getting started](docs/getting-started.md) ·
140
+ [desktop app](docs/desktop.md) ·
141
+ [analyze](docs/analyze.md) ·
119
142
  [concepts](docs/concepts.md) ·
120
143
  [targets & deploy](docs/targets.md) ·
144
+ [A2A](docs/a2a.md) ·
121
145
  [registry](docs/registry.md).
122
146
 
123
147
  ## License
@@ -0,0 +1,303 @@
1
+ // src/gem/acpRecommender.ts
2
+ //
3
+ // Turns a deterministic WorkflowSignal + inventory into a GemRecommendation by
4
+ // grounding a local ACP coding agent (Claude) with the signal and asking it to
5
+ // cluster/name/justify a Gem. The agent only ranks and explains — its output is
6
+ // re-validated against the inventory (the source of truth), and any failure
7
+ // degrades to a deterministic frequency-based recommendation. Never throws.
8
+ import { spawn } from "node:child_process";
9
+ import { mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { Readable, Writable } from "node:stream";
12
+ import { agentgemHome } from "../resolveDir.js";
13
+ // Instructions are a boolean on ProjectSelection, not a named include.
14
+ const SELECTABLE = ["skill", "mcp_server", "hook"];
15
+ // Pinned Claude ACP adapter (npm: @agentclientprotocol/claude-agent-acp).
16
+ export const CLAUDE_AGENT = { id: "claude-code", name: "Claude Code", command: ["claude-agent-acp"] };
17
+ // Neutral working dir for the recommender's ACP session. We do NOT open the
18
+ // session in the analyzed project, or claude-agent-acp would log a session
19
+ // transcript THERE — inflating that project's own session history (skewing
20
+ // future analyses and busting the per-project cache). The agent only reasons
21
+ // over the JSON brief, so its cwd is irrelevant to the result.
22
+ export function analysisWorkspace() { return join(agentgemHome(), ".agentgem", "analysis"); }
23
+ let testConnectFn = null;
24
+ /** Test-only seam: route recommendWorkflow through an in-process fake agent. */
25
+ export function setConnectFnForTests(fn) { testConnectFn = fn; }
26
+ // ── Deterministic analysis (fallback + the agent's baseline) ─────────────────
27
+ // One frequency-based candidate. Multi-candidate splitting is the agent's value-add;
28
+ // the deterministic fallback stays a single coherent Gem.
29
+ export function deterministicAnalysis(signal) {
30
+ const include = [];
31
+ let includeInstructions = false;
32
+ for (const a of signal.artifacts) {
33
+ if (a.type === "instructions") {
34
+ if (a.invocations > 0)
35
+ includeInstructions = true;
36
+ continue;
37
+ }
38
+ if (!SELECTABLE.includes(a.type))
39
+ continue;
40
+ if (a.invocations > 0 && a.confidence === "high")
41
+ include.push({ type: a.type, name: a.name, reason: `${a.invocations} use(s) across ${a.sessionsUsedIn} session(s)`, root: a.root });
42
+ }
43
+ const gaps = signal.unresolved.filter((u) => u.kind !== "builtin").map((u) => u.name);
44
+ const candidates = include.length ? [{
45
+ name: signal.root.split("/").pop() || "workflow",
46
+ description: `Recommended from ${signal.sessions.scanned} session(s) of usage.`,
47
+ root: signal.root,
48
+ includeInstructions,
49
+ include,
50
+ confidence: "medium",
51
+ }] : [];
52
+ return { candidates, gaps };
53
+ }
54
+ /**
55
+ * Map a validated candidate to a GemSelection. Global artifacts (root===null)
56
+ * go top-level; project artifacts go under projects[root]; instructions are a
57
+ * project boolean. buildGem resolves both namespaces from introspectAll.
58
+ */
59
+ export function recommendationToSelection(c) {
60
+ const sel = {};
61
+ const globalNames = (t) => c.include.filter((i) => i.type === t && i.root === null).map((i) => i.name);
62
+ const gSkills = globalNames("skill"), gMcp = globalNames("mcp_server"), gHooks = globalNames("hook");
63
+ if (gSkills.length)
64
+ sel.skills = gSkills;
65
+ if (gMcp.length)
66
+ sel.mcpServers = gMcp;
67
+ if (gHooks.length)
68
+ sel.hooks = gHooks;
69
+ const projects = {};
70
+ const ensure = (root) => (projects[root] ??= {});
71
+ for (const i of c.include) {
72
+ if (i.root === null)
73
+ continue;
74
+ const ps = ensure(i.root);
75
+ if (i.type === "skill")
76
+ (ps.skills ??= []).push(i.name);
77
+ else if (i.type === "mcp_server")
78
+ (ps.mcpServers ??= []).push(i.name);
79
+ else if (i.type === "hook")
80
+ (ps.hooks ??= []).push(i.name);
81
+ }
82
+ if (c.includeInstructions)
83
+ ensure(c.root).includeInstructions = true;
84
+ if (Object.keys(projects).length)
85
+ sel.projects = projects;
86
+ return sel;
87
+ }
88
+ // Pull the first {...} block out of an agent message that may wrap JSON in prose/fences.
89
+ function extractJson(text) {
90
+ const start = text.indexOf("{");
91
+ const end = text.lastIndexOf("}");
92
+ return start >= 0 && end > start ? text.slice(start, end + 1) : text;
93
+ }
94
+ /**
95
+ * Validate a raw agent response against the inventory. Each candidate's include
96
+ * names are checked against the inventory; hallucinated names are dropped
97
+ * (logged) and a candidate with no surviving includes is discarded. On any
98
+ * structural failure or zero valid candidates, fall back to the deterministic
99
+ * analysis. The inventory is authoritative.
100
+ */
101
+ export function validateAnalysis(raw, inv, signal) {
102
+ const fallback = deterministicAnalysis(signal);
103
+ let obj = raw;
104
+ if (typeof raw === "string") {
105
+ try {
106
+ obj = JSON.parse(extractJson(raw));
107
+ }
108
+ catch {
109
+ return fallback;
110
+ }
111
+ }
112
+ if (!obj || typeof obj !== "object" || !Array.isArray(obj.candidates))
113
+ return fallback;
114
+ const g = inv.global ?? { skills: [], mcpServers: [], hooks: [] };
115
+ // Resolve a name to its namespace: project root if present there, else global
116
+ // (null), else undefined (hallucinated). Project is preferred on collision.
117
+ const proj = {
118
+ skill: new Set(inv.project.skills.map((s) => s.name)),
119
+ mcp_server: new Set(inv.project.mcpServers.map((m) => m.name)),
120
+ hook: new Set(inv.project.hooks.map((h) => h.name)),
121
+ };
122
+ const glob = {
123
+ skill: new Set(g.skills.map((s) => s.name)),
124
+ mcp_server: new Set(g.mcpServers.map((m) => m.name)),
125
+ hook: new Set(g.hooks.map((h) => h.name)),
126
+ };
127
+ const resolveRoot = (type, name) => proj[type]?.has(name) ? inv.project.root : glob[type]?.has(name) ? null : undefined;
128
+ const candidates = [];
129
+ for (const c of obj.candidates) {
130
+ if (!c || typeof c !== "object" || !Array.isArray(c.include))
131
+ continue;
132
+ const include = [];
133
+ for (const it of c.include) {
134
+ if (!it || !SELECTABLE.includes(it.type) || typeof it.name !== "string")
135
+ continue;
136
+ const root = resolveRoot(it.type, it.name);
137
+ if (root === undefined) {
138
+ console.error(`workflow: dropping hallucinated ${it.type} '${it.name}'`);
139
+ continue;
140
+ }
141
+ include.push({ type: it.type, name: it.name, reason: typeof it.reason === "string" ? it.reason : "", root });
142
+ }
143
+ if (!include.length)
144
+ continue;
145
+ candidates.push({
146
+ name: typeof c.name === "string" ? c.name : (signal.root.split("/").pop() || "workflow"),
147
+ description: typeof c.description === "string" ? c.description : "",
148
+ root: signal.root,
149
+ includeInstructions: c.includeInstructions === true,
150
+ include,
151
+ confidence: ["high", "medium", "low"].includes(c.confidence) ? c.confidence : "medium",
152
+ });
153
+ }
154
+ if (!candidates.length)
155
+ return fallback;
156
+ const gaps = Array.isArray(obj.gaps) ? obj.gaps.filter((g) => typeof g === "string") : fallback.gaps;
157
+ return { candidates, gaps };
158
+ }
159
+ // ── The agent run ────────────────────────────────────────────────────────────
160
+ const GROUNDING = (signalJson, inventoryJson) => `You recommend reusable "Gems" — bundles of installed artifacts for a recurring workflow.\n` +
161
+ `A project often exercises SEVERAL distinct flows (e.g. diagram generation vs web scraping). ` +
162
+ `Use the per-session "shapes" (sets of artifacts used together) plus co-occurrence to identify each ` +
163
+ `recurring flow, and propose ONE Gem per flow.\n` +
164
+ `The inventory has PROJECT artifacts (scoped to this repo) and GLOBAL artifacts (from the machine / ` +
165
+ `installed plugins). Include either by exact name — both get bundled into the Gem.\n` +
166
+ `USAGE SIGNAL (authoritative — invocation counts and shapes are facts):\n${signalJson}\n\n` +
167
+ `INVENTORY (the only artifacts that exist — never invent names outside this):\n${inventoryJson}\n\n` +
168
+ `Return ONLY a JSON object: {"candidates":[{"name","description","includeInstructions":bool,` +
169
+ `"include":[{"type":"skill"|"mcp_server"|"hook","name","reason"}],"confidence":"high"|"medium"|"low"}],"gaps":[string]}.\n` +
170
+ `Each candidate is one coherent flow. Prefer 1–4 candidates; don't split trivially or duplicate. Use exact inventory names.`;
171
+ // Skill bodies are large; send descriptions only. Global section is limited to
172
+ // artifacts that actually fired (the global catalog can be huge) — `usedGlobal`.
173
+ function trimInventory(inv, usedGlobal) {
174
+ const p = inv.project;
175
+ const g = inv.global ?? { skills: [], mcpServers: [], hooks: [] };
176
+ return {
177
+ projectRoot: p.root, name: p.name,
178
+ project: {
179
+ skills: p.skills.map((s) => ({ name: s.name, description: s.description ?? "" })),
180
+ mcpServers: p.mcpServers.map((m) => ({ name: m.name, transport: m.transport })),
181
+ instructions: p.instructions.map((i) => ({ name: i.name })),
182
+ hooks: p.hooks.map((h) => ({ name: h.name, event: h.event, matcher: h.matcher ?? null })),
183
+ },
184
+ global: {
185
+ skills: g.skills.filter((s) => usedGlobal.has(s.name)).map((s) => ({ name: s.name })),
186
+ mcpServers: g.mcpServers.filter((m) => usedGlobal.has(m.name)).map((m) => ({ name: m.name })),
187
+ hooks: g.hooks.filter((h) => usedGlobal.has(h.name)).map((h) => ({ name: h.name, event: h.event })),
188
+ },
189
+ };
190
+ }
191
+ function withTimeout(p, ms) {
192
+ return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`agent timeout after ${ms}ms`)), ms))]);
193
+ }
194
+ /**
195
+ * Analyse `signal`/`inventory` into candidate Gems. Total: never throws. On any
196
+ * agent error/timeout/junk, returns the deterministic analysis with degraded:true.
197
+ */
198
+ export async function recommendWorkflow(signal, inv, opts = {}) {
199
+ const connectFn = opts.connectFn ?? testConnectFn ?? defaultConnectFn;
200
+ const timeoutMs = opts.timeoutMs ?? 60_000;
201
+ let conn = null;
202
+ let handle = null;
203
+ try {
204
+ const usedGlobal = new Set(signal.artifacts.filter((a) => a.root === null && a.invocations > 0).map((a) => a.name));
205
+ const trimmedInv = trimInventory(inv, usedGlobal);
206
+ conn = await connectFn(CLAUDE_AGENT, null);
207
+ handle = await conn.ctx.open(analysisWorkspace()); // neutral cwd — don't pollute the project
208
+ await handle.setMode("plan"); // explicit — never edits files
209
+ const prompt = GROUNDING(JSON.stringify(signal), JSON.stringify(trimmedInv));
210
+ const text = await withTimeout(handle.promptText(prompt, opts.onDelta), timeoutMs);
211
+ return { analysis: validateAnalysis(text, inv, signal), degraded: false };
212
+ }
213
+ catch (err) {
214
+ console.error("workflow: recommender fell back to deterministic:", err.message);
215
+ return { analysis: deterministicAnalysis(signal), degraded: true };
216
+ }
217
+ finally {
218
+ try {
219
+ handle?.dispose();
220
+ }
221
+ catch { /* ignore */ }
222
+ try {
223
+ conn?.close();
224
+ }
225
+ catch { /* ignore */ }
226
+ }
227
+ }
228
+ /**
229
+ * Real connect: spawn the ACP adapter and bridge stdio via the SDK. Wrapped so
230
+ * the rest of the module is SDK-agnostic. Mirrors agentback console-chat's
231
+ * defaultConnectFn, minus the workspace PATH walk and permission routing — this
232
+ * agent runs in plan mode and we auto-deny any permission request.
233
+ *
234
+ * NEEDS LIVE VALIDATION: stdio bridging + set_mode against claude-agent-acp.
235
+ */
236
+ export const defaultConnectFn = async (descriptor) => {
237
+ const { client, ndJsonStream } = await import("@agentclientprotocol/sdk");
238
+ const [bin, ...args] = descriptor.command;
239
+ const child = spawn(bin, args, { stdio: ["pipe", "pipe", "inherit"], env: process.env });
240
+ await new Promise((resolve, reject) => {
241
+ child.once("spawn", () => resolve());
242
+ child.once("error", (e) => reject(new Error(`failed to spawn ${bin}: ${e.message}`)));
243
+ });
244
+ const app = client({ name: "agentgem-workflow-recommender" });
245
+ // Auto-deny any permission request — the recommender must not run tools.
246
+ app.onRequest?.("session/request_permission", async () => ({ outcome: { outcome: "cancelled" } }));
247
+ const input = Readable.toWeb(child.stdout);
248
+ const output = Writable.toWeb(child.stdin);
249
+ const connection = app.connect(ndJsonStream(output, input));
250
+ const agentCtx = connection.agent;
251
+ const ctx = {
252
+ async open(cwd) {
253
+ try {
254
+ mkdirSync(cwd, { recursive: true });
255
+ }
256
+ catch { /* best-effort */ }
257
+ const session = await agentCtx.buildSession(cwd).start();
258
+ const sessionId = session.sessionId;
259
+ return {
260
+ async setMode(mode) {
261
+ try {
262
+ await agentCtx.request("session/set_mode", { sessionId, modeId: mode });
263
+ }
264
+ catch { /* best-effort */ }
265
+ },
266
+ async promptText(text, onDelta) {
267
+ let out = "";
268
+ void session.prompt(text);
269
+ for (;;) {
270
+ const msg = await session.nextUpdate();
271
+ if (msg.kind === "stop")
272
+ break;
273
+ if (msg.kind === "session_update" && msg.update?.sessionUpdate === "agent_message_chunk") {
274
+ const block = msg.update.content;
275
+ if (block?.type === "text" && typeof block.text === "string") {
276
+ out += block.text;
277
+ onDelta?.(block.text);
278
+ }
279
+ }
280
+ }
281
+ return out;
282
+ },
283
+ dispose() { try {
284
+ session.dispose?.();
285
+ }
286
+ catch { /* ignore */ } },
287
+ };
288
+ },
289
+ };
290
+ return {
291
+ ctx,
292
+ close: () => {
293
+ try {
294
+ connection.close();
295
+ }
296
+ catch { /* ignore */ }
297
+ try {
298
+ child.kill();
299
+ }
300
+ catch { /* ignore */ }
301
+ },
302
+ };
303
+ };
@@ -0,0 +1,51 @@
1
+ // src/gem/analysisCache.ts
2
+ //
3
+ // Per-project cache of the (expensive, ~15-20s) workflow analysis. Keyed by the
4
+ // project root and a transcript "token" that changes whenever a session is added
5
+ // or updated — so the cache stays valid until the project's sessions change, and
6
+ // revisiting a project to pick a different candidate is instant. Best-effort and
7
+ // persistent (~/.agentgem/analysis-cache.json); failures never throw.
8
+ import { readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { agentgemHome } from "../resolveDir.js";
11
+ const MAX_ENTRIES = 50;
12
+ function cachePath() { return join(agentgemHome(), ".agentgem", "analysis-cache.json"); }
13
+ /** A cheap validity token: transcript count + newest mtime. New/updated session → new token. */
14
+ export function transcriptToken(paths) {
15
+ let maxMs = 0;
16
+ for (const p of paths) {
17
+ try {
18
+ const m = statSync(p).mtimeMs;
19
+ if (m > maxMs)
20
+ maxMs = m;
21
+ }
22
+ catch { /* gone — ignore */ }
23
+ }
24
+ return `${paths.length}:${Math.round(maxMs)}`;
25
+ }
26
+ function readAll() {
27
+ try {
28
+ const j = JSON.parse(readFileSync(cachePath(), "utf8"));
29
+ return Array.isArray(j) ? j : [];
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
35
+ /** Cached result for (root, token), or null on miss/stale. */
36
+ export function readAnalysisCache(root, token) {
37
+ const e = readAll().find((x) => x.root === root && x.token === token);
38
+ return e ? e.result : null;
39
+ }
40
+ /** Store (root, token) → result, replacing any prior entry for root. Capped + best-effort. */
41
+ export function writeAnalysisCache(root, token, result, nowMs) {
42
+ try {
43
+ const all = readAll().filter((x) => x.root !== root);
44
+ all.push({ root, token, result, ts: nowMs });
45
+ all.sort((a, b) => b.ts - a.ts);
46
+ const path = cachePath();
47
+ mkdirSync(dirname(path), { recursive: true });
48
+ writeFileSync(path, JSON.stringify(all.slice(0, MAX_ENTRIES)), "utf8");
49
+ }
50
+ catch { /* best-effort */ }
51
+ }
@@ -499,6 +499,221 @@ const eveComposeProject = (gem, opts = {}) => {
499
499
  }
500
500
  return rendered(files);
501
501
  };
502
+ // ── A2A (Agent2Agent) target ──
503
+ // Card primitive: materialize(gem, "a2a") emits a runtime-free Agent Card derived from the gem — the
504
+ // A2A discovery surface, publishable to the registry. The Card is the part native to AgentGem's
505
+ // "describe an agent" mission; a runnable A2A server is a planned opt-in flavor (MaterializeOpts).
506
+ const A2A_PROTOCOL_VERSION = "0.3.0";
507
+ const a2aSkillCard = (a) => ({
508
+ id: safePathSegment(a.name),
509
+ name: a.name,
510
+ description: a.description?.trim() || `The ${a.name} skill.`,
511
+ tags: ["skill"],
512
+ });
513
+ // A one-line card description from an instruction artifact: prefer the first non-empty *prose* line
514
+ // (instruction files usually open with a throwaway "# Title" heading); fall back to the de-headed
515
+ // first line if the doc is headings-only.
516
+ const a2aFirstLine = (s) => {
517
+ const lines = s.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
518
+ return lines.find((l) => !l.startsWith("#")) ?? lines[0]?.replace(/^#+\s*/, "") ?? "";
519
+ };
520
+ // Pure Gem -> AgentCard projection. Skills advertise as A2A skills (metadata, not bodies); the first
521
+ // instruction line becomes the card description; a skill-less Gem gets a synthesized `chat` skill
522
+ // (A2A requires >=1). Emits no secret values (skills/instructions carry none post-redaction).
523
+ export const a2aAgentCard = (gem) => {
524
+ const skills = gem.artifacts.filter((a) => a.type === "skill");
525
+ const instr = gem.artifacts.filter((a) => a.type === "instructions");
526
+ const cardSkills = skills.map(a2aSkillCard);
527
+ return {
528
+ protocolVersion: A2A_PROTOCOL_VERSION,
529
+ name: gem.name,
530
+ description: a2aFirstLine(instr[0]?.content ?? "") || `An agent packaged by AgentGem from ${skills.length} skill(s).`,
531
+ version: "0.1.0",
532
+ url: "http://localhost:41241/a2a/jsonrpc", // discovery placeholder; the (future) server overrides from PUBLIC_URL
533
+ capabilities: { streaming: false, pushNotifications: false },
534
+ defaultInputModes: ["text"],
535
+ defaultOutputModes: ["text"],
536
+ skills: cardSkills.length ? cardSkills
537
+ : [{ id: "chat", name: "chat", description: `Converse with ${gem.name}.`, tags: ["chat"] }],
538
+ };
539
+ };
540
+ // ── A2A server mode (opt-in via MaterializeOpts.a2aServer) ──
541
+ // Runtime is Vercel AI SDK v7 (`ai` + `@ai-sdk/mcp`), vendor-neutral via the gateway model string —
542
+ // deliberately NOT @openai/agents, so sandboxMcpServer is not reused (a2aMcpClient is its analogue).
543
+ const A2A_MODEL = "anthropic/claude-sonnet-4-6";
544
+ const a2aMcpClient = (s) => {
545
+ const url = typeof s.config.url === "string" ? s.config.url : "";
546
+ if (/^https?:\/\//.test(url)) {
547
+ const refs = s.secretRefs ?? [];
548
+ const unsupported = refs.find((r) => !/^headers\./i.test(r.location));
549
+ if (unsupported)
550
+ return { skip: `A2A (AI SDK) cannot map secret at ${unsupported.location}` };
551
+ const authorization = refs.find((r) => r.location.toLowerCase() === "headers.authorization");
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
+ ];
557
+ const headers = headerEntries.length
558
+ ? `, headers: { ${headerEntries.map(([h, e]) => `${JSON.stringify(h)}: process.env[${JSON.stringify(e)}]!`).join(", ")} }`
559
+ : "";
560
+ const type = s.transport === "sse" ? "sse" : "http";
561
+ return { code: ` createMCPClient({ transport: { type: ${JSON.stringify(type)}, url: ${JSON.stringify(url)}${headers} } }),`, stdio: false };
562
+ }
563
+ if (s.transport === "stdio" && typeof s.config.command === "string") {
564
+ const args = Array.isArray(s.config.args) ? s.config.args.filter((a) => typeof a === "string") : [];
565
+ const envNames = (s.secretRefs ?? []).map((r) => r.name);
566
+ const envStr = envNames.length ? `, env: { ${envNames.map((n) => `${JSON.stringify(n)}: process.env[${JSON.stringify(n)}]!`).join(", ")} }` : "";
567
+ return { code: ` createMCPClient({ transport: new Experimental_StdioMCPTransport({ command: ${JSON.stringify(s.config.command)}, args: ${JSON.stringify(args)}${envStr} }) }),`, stdio: true };
568
+ }
569
+ return { skip: `${s.transport} MCP has no usable URL or stdio command` };
570
+ };
571
+ // A2A projects authenticate via plain process.env. Deliberately NOT agentcoreSecretsMd (its
572
+ // `agentcore add credential` / ${arn:...} body is wrong for an A2A/AI-SDK project).
573
+ const a2aSecretsMd = (secrets) => {
574
+ const model = `## Model access\n\nThe agent calls \`${A2A_MODEL}\` via the AI SDK. Set \`AI_GATEWAY_API_KEY\` ` +
575
+ `(Vercel AI Gateway) or a direct provider key (e.g. \`ANTHROPIC_API_KEY\`).\n`;
576
+ const access = `## Access control (optional)\n\nSet \`A2A_API_KEY\` to require \`Authorization: Bearer <key>\` on the ` +
577
+ `agent's JSON-RPC/REST routes. Agent Card discovery (the \`.well-known\` endpoint) stays open.\n`;
578
+ const mcp = secrets.length
579
+ ? `## MCP credentials\n\nSet these before \`npm start\` (e.g. a \`.env\` file):\n\n` +
580
+ `${secrets.map((s) => `- \`${s.name}\` (for ${s.artifact} at ${s.location})`).join("\n")}\n`
581
+ : `## MCP credentials\n\nThis agent declares no MCP secrets.\n`;
582
+ return `# Secrets\n\n${model}\n${access}\n${mcp}`;
583
+ };
584
+ const a2aPackageJson = (gemName) => JSON.stringify({
585
+ name: safePathSegment(gemName).toLowerCase(), version: "0.1.0", private: true, type: "module",
586
+ scripts: { build: "tsc", start: "node dist/server.js", dev: "tsx src/server.ts" },
587
+ // Verified pins: ai v7 beta pairs with @ai-sdk/mcp v2 beta; @a2a-js/sdk 0.3.x.
588
+ dependencies: { "@a2a-js/sdk": "^0.3.13", ai: "7.0.0-beta.178", "@ai-sdk/mcp": "2.0.0-beta.67", express: "^5", uuid: "^11" },
589
+ devDependencies: { "@types/express": "^5", "@types/node": "^24", tsx: "^4", typescript: "^5" },
590
+ }, null, 2) + "\n";
591
+ // The runnable A2A server: an AI SDK `streamText` tool loop behind the @a2a-js/sdk JSON-RPC handler.
592
+ // Streams incrementally via the A2A task lifecycle (submitted -> working -> artifact-update* ->
593
+ // completed); the same executor serves message/send (aggregated) and message/stream (SSE). The served
594
+ // card advertises streaming: true and rebinds `url` from PUBLIC_URL (the static card carries neither).
595
+ const a2aServerTs = (system, clientCodes, usesStdio) => {
596
+ const mcpImports = clientCodes.length
597
+ ? `import { createMCPClient } from "@ai-sdk/mcp";\n${usesStdio ? `import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";\n` : ""}`
598
+ : "";
599
+ const bootBlock = clientCodes.length
600
+ ? `const mcpClients = await Promise.all([\n${clientCodes.join("\n")}\n]);
601
+ const tools = Object.assign({}, ...(await Promise.all(mcpClients.map((c) => c.tools()))));
602
+ for (const sig of ["SIGINT", "SIGTERM"] as const)
603
+ process.on(sig, () => { Promise.allSettled(mcpClients.map((c) => c.close())).finally(() => process.exit(0)); });`
604
+ : `const tools = {};`;
605
+ return `import express from "express";
606
+ import { streamText, stepCountIs } from "ai";
607
+ ${mcpImports}import { type AgentCard, AGENT_CARD_PATH } from "@a2a-js/sdk";
608
+ import { type AgentExecutor, type RequestContext, type ExecutionEventBus,
609
+ DefaultRequestHandler, InMemoryTaskStore, InMemoryPushNotificationStore, DefaultPushNotificationSender } from "@a2a-js/sdk/server";
610
+ import { agentCardHandler, jsonRpcHandler, restHandler, UserBuilder } from "@a2a-js/sdk/server/express";
611
+ import { v4 as uuid } from "uuid";
612
+ import cardBase from "../agent-card.json" with { type: "json" };
613
+
614
+ const MODEL = ${JSON.stringify(A2A_MODEL)};
615
+ const SYSTEM = \`${escapeTemplate(system)}\`;
616
+
617
+ const port = Number(process.env.PORT ?? 41241);
618
+ const baseUrl = process.env.PUBLIC_URL ?? \`http://localhost:\${port}\`;
619
+ const API_KEY = process.env.A2A_API_KEY; // when set, require \`Authorization: Bearer <key>\` on the RPC/REST routes
620
+ const card: AgentCard = { ...(cardBase as AgentCard), url: \`\${baseUrl}/a2a/jsonrpc\`,
621
+ capabilities: { ...(cardBase as AgentCard).capabilities, streaming: true, pushNotifications: true },
622
+ additionalInterfaces: [
623
+ { url: \`\${baseUrl}/a2a/jsonrpc\`, transport: "JSONRPC" },
624
+ { url: \`\${baseUrl}/a2a/rest\`, transport: "HTTP+JSON" },
625
+ ],
626
+ ...(API_KEY ? { securitySchemes: { bearer: { type: "http", scheme: "bearer" } }, security: [{ bearer: [] }] } : {}) };
627
+
628
+ ${bootBlock}
629
+
630
+ // Streaming executor: drive the tool loop and publish A2A task-lifecycle + artifact-update events.
631
+ class GemExecutor implements AgentExecutor {
632
+ private inflight = new Map<string, AbortController>();
633
+ async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
634
+ const { taskId, contextId, userMessage, task } = ctx;
635
+ const text = (userMessage.parts ?? []).filter((p: any) => p.kind === "text").map((p: any) => p.text).join("\\n");
636
+ const ac = new AbortController();
637
+ this.inflight.set(taskId, ac);
638
+ if (!task) bus.publish({ kind: "task", id: taskId, contextId, status: { state: "submitted", timestamp: new Date().toISOString() }, history: [userMessage] });
639
+ bus.publish({ kind: "status-update", taskId, contextId, status: { state: "working", timestamp: new Date().toISOString() }, final: false });
640
+ const artifactId = uuid();
641
+ let started = false;
642
+ try {
643
+ const result = streamText({ model: MODEL, system: SYSTEM, tools, stopWhen: stepCountIs(10), prompt: text, abortSignal: ac.signal });
644
+ for await (const delta of result.textStream) {
645
+ bus.publish({ kind: "artifact-update", taskId, contextId, append: started, lastChunk: false,
646
+ artifact: { artifactId, name: "response", parts: [{ kind: "text", text: delta }] } });
647
+ started = true;
648
+ }
649
+ bus.publish({ kind: "artifact-update", taskId, contextId, append: true, lastChunk: true, artifact: { artifactId, parts: [] } });
650
+ bus.publish({ kind: "status-update", taskId, contextId, status: { state: "completed", timestamp: new Date().toISOString() }, final: true });
651
+ } catch (err) {
652
+ const state = ac.signal.aborted ? "canceled" : "failed";
653
+ bus.publish({ kind: "status-update", taskId, contextId, status: { state, timestamp: new Date().toISOString() }, final: true });
654
+ } finally {
655
+ this.inflight.delete(taskId);
656
+ bus.finished();
657
+ }
658
+ }
659
+ cancelTask = async (taskId: string): Promise<void> => { this.inflight.get(taskId)?.abort(); };
660
+ }
661
+
662
+ const pushStore = new InMemoryPushNotificationStore();
663
+ const requestHandler = new DefaultRequestHandler(card, new InMemoryTaskStore(), new GemExecutor(),
664
+ undefined, pushStore, new DefaultPushNotificationSender(pushStore));
665
+ const app = express();
666
+ // Discovery (the /.well-known Agent Card) stays open; gate only the invocation routes when A2A_API_KEY is set.
667
+ const requireAuth: express.RequestHandler = (req, res, next) => {
668
+ if (!API_KEY || req.headers.authorization === \`Bearer \${API_KEY}\`) return next();
669
+ res.status(401).json({ error: "unauthorized" });
670
+ };
671
+ app.use("/a2a", requireAuth);
672
+ app.use(\`/\${AGENT_CARD_PATH}\`, agentCardHandler({ agentCardProvider: requestHandler }));
673
+ app.use("/a2a/jsonrpc", jsonRpcHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
674
+ app.use("/a2a/rest", restHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
675
+ app.listen(port, () => console.log(\`A2A agent "\${card.name}" listening on :\${port}\`));
676
+ `;
677
+ };
678
+ // A2A is wholly compose-driven: per-type renderers are no-ops (so no artifact is skip-reported), and
679
+ // compose emits the Agent Card. Card-only mode models neither MCP nor hooks, so nothing is skipped.
680
+ // With opts.a2aServer, it additionally emits a runnable server and evaluates MCP/hook mappability.
681
+ const a2aComposeProject = (gem, opts = {}) => {
682
+ 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
+ const mcps = gem.artifacts.filter((a) => a.type === "mcp_server");
688
+ const hooks = gem.artifacts.filter((a) => a.type === "hook");
689
+ // AI SDK has no skills primitive -> fold skill bodies (frontmatter-stripped) into the system prompt.
690
+ const instrText = instr.map((i) => `## ${i.name}\n\n${i.content}`).join("\n\n---\n\n");
691
+ const skillText = skills.map((s) => `## Skill: ${s.name}\n\n${stripYamlFrontmatter(s.content)}`).join("\n\n---\n\n");
692
+ const system = [instrText, skillText].filter(Boolean).join("\n\n---\n\n");
693
+ const skipped = [];
694
+ const clientCodes = [];
695
+ let usesStdio = false;
696
+ for (const s of mcps) {
697
+ const r = a2aMcpClient(s);
698
+ if ("skip" in r) {
699
+ skipped.push({ artifact: s.name, type: "mcp_server", reason: r.skip });
700
+ continue;
701
+ }
702
+ clientCodes.push(r.code);
703
+ usesStdio ||= r.stdio;
704
+ }
705
+ for (const h of hooks)
706
+ skipped.push({ artifact: h.name, type: "hook", reason: "A2A has no hook concept" });
707
+ return {
708
+ files: {
709
+ ...files,
710
+ "src/server.ts": a2aServerTs(system, clientCodes, usesStdio),
711
+ "package.json": a2aPackageJson(gem.name),
712
+ "SECRETS.md": a2aSecretsMd(gem.requiredSecrets),
713
+ },
714
+ skipped,
715
+ };
716
+ };
502
717
  // ── targets compose the shared renderers (convergence is literal, not duplicated) ──
503
718
  export const TARGET_REGISTRY = {
504
719
  claude: { id: "claude", label: "Claude", skill: skillSkillMd, instructions: instructionsClaudeMd, mcp: mcpDotMcpJson, hook: hooksSettingsJson },
@@ -516,6 +731,9 @@ export const TARGET_REGISTRY = {
516
731
  // AgentCore harness project (app/<gem>/harness.json + container-baked skills). Instructions/MCP
517
732
  // fold into the composed harness.json; stdio MCP is reported skipped by compose; hooks unsupported.
518
733
  agentcore: { id: "agentcore", label: "AgentCore", skill: skillAgentcoreMd, instructions: () => ({}), mcp: () => ({ files: {}, skipped: [] }), compose: agentcoreComposeProject },
734
+ // A2A Agent Card primitive. Wholly compose-driven (all per-type renderers no-op); compose emits the
735
+ // runtime-free agent-card.json. Card-only mode reports nothing skipped.
736
+ a2a: { id: "a2a", label: "A2A", skill: () => ({}), instructions: () => ({}), mcp: () => ({ files: {}, skipped: [] }), hook: () => ({}), compose: a2aComposeProject },
519
737
  };
520
738
  export function materialize(gem, target, opts = {}) {
521
739
  const spec = TARGET_REGISTRY[target];
@@ -133,6 +133,7 @@ export function discoverProjects(dirs) {
133
133
  }
134
134
  }
135
135
  return [...best.values()]
136
+ .filter((p) => !p.path.includes("/.agentgem/")) // hide agentgem's own internal workspaces (e.g. the analysis cwd)
136
137
  .sort((a, b) => b.lastUsedMs - a.lastUsedMs)
137
138
  .map((p) => ({
138
139
  path: p.path,
Binary file
@@ -15,7 +15,9 @@ import { packTar } from "./gem/archiveTar.js";
15
15
  import { readDeployRecord, writeDeployRecord, clearDeployRecord } from "./gem/deployRecord.js";
16
16
  import { undeployManagedAgent, anthropicPublishClient } from "./publish.js";
17
17
  import { undeployAgentcoreHarness, realAgentcoreControlClient } from "./gem/agentcorePublish.js";
18
- import { InventorySchema, GemSchema, GemRequestSchema, DirQuerySchema, PickQuerySchema, PickFolderSchema, ScaffoldChecksRequestSchema, ScaffoldChecksResponseSchema, MaterializeRequestSchema, MaterializeResponseSchema, PublishPreviewRequestSchema, PublishRequestSchema, PublishPreviewResponseSchema, PublishReadyResponseSchema, PublishResultSchema, DeployTargetsResponseSchema, DeployReadyQuerySchema, ArchiveRequestSchema, ArchiveResponseSchema, CreateWorkspaceRequestSchema, WorkspaceQuerySchema, RenderRequestSchema, WorkspaceNameRequestSchema, WorkspaceSummarySchema, WorkspaceDetailSchema, RenderResultSchema, ListWorkspacesResponseSchema, DeleteWorkspaceResponseSchema, RunReadyQuerySchema, RunReadyResponseSchema, RunRequestSchema, RunStatusQuerySchema, RunStateSchema, RunStopRequestSchema, RunStopResponseSchema, CredentialRequestSchema, CredentialResponseSchema, TestbedDetectQuerySchema, TestbedDetectResponseSchema, TestbedSuggestionQuerySchema, TestbedSuggestionResponseSchema, TestbedRecentsResponseSchema, TestbedProjectsQuerySchema, TestbedProjectsResponseSchema, TestbedScaffoldRequestSchema, TestbedScaffoldResponseSchema, TestbedImportRequestSchema, TestbedImportResponseSchema, AgentcoreReadyResponseSchema, AgentcoreDeployRequestSchema, AgentcoreStatusQuerySchema, AgentcoreDeployStateSchema, RegistryReadyResponseSchema, RegistryIndexResponseSchema, RegistryResolveRequestSchema, RegistryResolveResponseSchema, RegistryInstallRequestSchema, RegistryInstallResponseSchema, RegistryPublishRequestSchema, RegistryPublishResponseSchema, UndeployRequestSchema, UndeployResponseSchema, DeployRecordQuerySchema, DeployRecordResponseSchema, } from "./schemas.js";
18
+ import { InventorySchema, GemSchema, GemRequestSchema, DirQuerySchema, PickQuerySchema, PickFolderSchema, ScaffoldChecksRequestSchema, ScaffoldChecksResponseSchema, MaterializeRequestSchema, MaterializeResponseSchema, PublishPreviewRequestSchema, PublishRequestSchema, PublishPreviewResponseSchema, PublishReadyResponseSchema, PublishResultSchema, DeployTargetsResponseSchema, DeployReadyQuerySchema, ArchiveRequestSchema, ArchiveResponseSchema, CreateWorkspaceRequestSchema, WorkspaceQuerySchema, RenderRequestSchema, WorkspaceNameRequestSchema, WorkspaceSummarySchema, WorkspaceDetailSchema, RenderResultSchema, ListWorkspacesResponseSchema, DeleteWorkspaceResponseSchema, RunReadyQuerySchema, RunReadyResponseSchema, RunRequestSchema, RunStatusQuerySchema, RunStateSchema, RunStopRequestSchema, RunStopResponseSchema, CredentialRequestSchema, CredentialResponseSchema, TestbedDetectQuerySchema, TestbedDetectResponseSchema, TestbedSuggestionQuerySchema, TestbedSuggestionResponseSchema, TestbedRecentsResponseSchema, TestbedProjectsQuerySchema, TestbedProjectsResponseSchema, TestbedScaffoldRequestSchema, TestbedScaffoldResponseSchema, TestbedImportRequestSchema, TestbedImportResponseSchema, AgentcoreReadyResponseSchema, AgentcoreDeployRequestSchema, AgentcoreStatusQuerySchema, AgentcoreDeployStateSchema, RegistryReadyResponseSchema, RegistryIndexResponseSchema, RegistryResolveRequestSchema, RegistryResolveResponseSchema, RegistryInstallRequestSchema, RegistryInstallResponseSchema, RegistryPublishRequestSchema, RegistryPublishResponseSchema, UndeployRequestSchema, UndeployResponseSchema, DeployRecordQuerySchema, DeployRecordResponseSchema, WorkflowAnalyzeRequestSchema, WorkflowAnalyzeResponseSchema, } from "./schemas.js";
19
+ import { claudeTranscriptsForCwd, scanWorkflow } from "./gem/workflowScan.js";
20
+ import { recommendWorkflow, recommendationToSelection } from "./gem/acpRecommender.js";
19
21
  import { runReadiness, startLocal, stopLocal, getRunStatus, deployVercel, deployCloudflare, undeployVercel, undeployCloudflare } from "./gem/run.js";
20
22
  import { setCredential } from "./gem/credentials.js";
21
23
  import { agentcoreReadiness, deployAgentcore, getAgentcoreStatus } from "./gem/agentcoreRun.js";
@@ -56,7 +58,7 @@ let GemController = class GemController {
56
58
  const inventory = introspectAll(input.body.dir, input.body.projects);
57
59
  gem = buildGem(inventory, input.body.selection, { name: input.body.name ?? "gem", createdFrom: dirs.claudeDir });
58
60
  }
59
- return { target, ...materialize(gem, target), compatibility: compatibility(gem) };
61
+ return { target, ...materialize(gem, target, { a2aServer: input.body.a2aServer }), compatibility: compatibility(gem) };
60
62
  }
61
63
  async archive(input) {
62
64
  const dirs = resolveDirs(input.body.dir);
@@ -274,6 +276,29 @@ let GemController = class GemController {
274
276
  async pickFolder(_input) {
275
277
  return { path: await pickFolder() };
276
278
  }
279
+ async workflowAnalyze(input) {
280
+ const { dir, root } = input.body;
281
+ // Inventory for exactly this one project (project-namespaced selection target).
282
+ const inventory = introspectAll(dir, [root]);
283
+ // introspectAll canonicalizes roots via resolveProject (path.resolve); match the same way.
284
+ const project = (inventory.projects ?? []).find((p) => p.root === resolveProject(root));
285
+ if (!project)
286
+ throw new Error(`Project '${root}' not found in inventory`);
287
+ const dirs = resolveDirs(dir);
288
+ const paths = claudeTranscriptsForCwd(dirs.claudeDir, root);
289
+ // The top-level inventory IS the global/plugin inventory; the project section
290
+ // is namespaced separately. Scan + recommend over both.
291
+ const scanInv = { project, global: { skills: inventory.skills, mcpServers: inventory.mcpServers, hooks: inventory.hooks } };
292
+ const signal = scanWorkflow(paths, scanInv);
293
+ const { analysis, degraded } = await recommendWorkflow(signal, scanInv);
294
+ const candidates = analysis.candidates.map((c) => ({ ...c, selection: recommendationToSelection(c) }));
295
+ return {
296
+ candidates,
297
+ gaps: analysis.gaps,
298
+ signalSummary: { sessionsScanned: signal.sessions.scanned, spanDays: signal.sessions.spanDays, notes: signal.notes },
299
+ degraded,
300
+ };
301
+ }
277
302
  };
278
303
  __decorate([
279
304
  get("/inventory", { query: DirQuerySchema, response: InventorySchema }),
@@ -491,6 +516,12 @@ __decorate([
491
516
  __metadata("design:paramtypes", [Object]),
492
517
  __metadata("design:returntype", Promise)
493
518
  ], GemController.prototype, "pickFolder", null);
519
+ __decorate([
520
+ post("/workflow/analyze", { body: WorkflowAnalyzeRequestSchema, response: WorkflowAnalyzeResponseSchema }),
521
+ __metadata("design:type", Function),
522
+ __metadata("design:paramtypes", [Object]),
523
+ __metadata("design:returntype", Promise)
524
+ ], GemController.prototype, "workflowAnalyze", null);
494
525
  GemController = __decorate([
495
526
  api({ basePath: "/api" })
496
527
  ], GemController);
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { MCPComponent } from "@agentback/mcp";
17
17
  import { installMcpHttp } from "@agentback/mcp-http";
18
18
  import { GemController } from "./gem.controller.js";
19
19
  import { GemTools } from "./gem.tools.js";
20
+ import { streamWorkflowAnalyze } from "./workflowStream.js";
20
21
  const here = dirname(fileURLToPath(import.meta.url));
21
22
  function pageHtml() {
22
23
  for (const p of [join(here, "public", "index.html"), join(here, "..", "src", "public", "index.html")]) {
@@ -39,6 +40,10 @@ export async function createApp(port) {
39
40
  const server = await app.restServer;
40
41
  const html = pageHtml();
41
42
  server.expressApp.get("/", (_req, res) => res.type("html").send(html));
43
+ // SSE progress stream for workflow analysis (raw Express — the decorator
44
+ // framework only returns single JSON bodies). The POST /api/workflow/analyze
45
+ // route stays for programmatic/test callers.
46
+ server.expressApp.get("/api/workflow/analyze/stream", streamWorkflowAnalyze);
42
47
  return app;
43
48
  }
44
49
  // Start the server and print where its surfaces live. Shared by the default
@@ -2,6 +2,11 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
5
+ <!-- Same-origin by default. Inline script/style are required by this single-file UI;
6
+ no eval is used, so 'unsafe-eval' is omitted (this clears Electron's insecure-CSP
7
+ warning). Google Fonts (stylesheet + font files) and the data: SVG favicon are
8
+ allowlisted; API calls are same-origin. -->
9
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'self'; object-src 'none'" />
5
10
  <title>agentgem — Gem Builder</title>
6
11
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40' fill='none'><path d='M20 2 L36 14 L20 38 L4 14 Z' fill='%239a3324'/><path d='M20 2 L36 14 L20 16 Z' fill='%23bb4a38'/><path d='M20 2 L4 14 L20 16 Z' fill='%237d2a1e'/><path d='M4 14 L20 16 L20 38 Z' fill='%238b2e21'/><path d='M36 14 L20 16 L20 38 Z' fill='%23a8392a'/><path d='M4 14 L36 14 L20 16 Z' fill='%23c8543f'/></svg>" />
7
12
  <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -202,7 +207,7 @@
202
207
  </nav>
203
208
  <main>
204
209
  <section class="pane left">
205
- <div class="bar" id="nameBar" style="display:none"><input id="name" type="text" placeholder="gem name" value="gem" style="flex:1" /></div>
210
+ <div class="bar" id="nameBar" style="display:none"><label for="name" class="d" style="white-space:nowrap">Gem name</label><input id="name" type="text" placeholder="gem name" value="gem" style="flex:1" /></div>
206
211
  <div class="bar" id="wsBar" style="display:none">
207
212
  <select id="wsSelect" title="open a saved workspace" style="flex:1"><option value="">— no workspace —</option></select>
208
213
  <button id="wsNew" class="ghost" title="save the current selection as a new workspace">New workspace…</button>
@@ -246,7 +251,7 @@
246
251
  </div>
247
252
  </section>
248
253
  <section class="pane right">
249
- <div class="bar"><strong style="flex:1">Gem (live)</strong><select id="target" title="materialize target" style="margin-left:auto"><option value="claude">Claude</option><option value="codex">Codex</option><option value="agents">Agents</option><option value="hermes">Hermes</option><option value="eve">Eve</option><option value="flue">Flue</option><option value="openai-sandbox">OpenAI Sandbox</option><option value="agentcore">AgentCore</option></select><span class="seg" id="preview-modes"><button type="button" data-pmode="summary">Summary</button><button type="button" data-pmode="json">JSON</button><button type="button" data-pmode="materialize">Materialize</button><button type="button" data-pmode="managed">Managed Agents</button></span><span class="exportwrap"><button id="exportBtn">Export ▾</button><div id="exportMenu" class="exportmenu" hidden><button id="dltar" class="menuitem" title="download the gem archive as a .tar.gz">⬇ Download .tar.gz</button><button id="save" class="menuitem" title="write the gem archive (manifest + lock + files) to a folder">💾 Save to folder…</button><button id="dl" class="menuitem">⬇ Download JSON</button><button id="copy" class="menuitem">⧉ Copy JSON</button></div></span><span class="d" id="archiveStatus" style="margin-left:6px"></span></div>
254
+ <div class="bar"><strong style="flex:1">Gem (live)</strong><select id="target" title="materialize target" style="margin-left:auto"><option value="claude">Claude</option><option value="codex">Codex</option><option value="agents">Agents</option><option value="hermes">Hermes</option><option value="eve">Eve</option><option value="flue">Flue</option><option value="openai-sandbox">OpenAI Sandbox</option><option value="agentcore">AgentCore</option><option value="a2a">A2A</option></select><span class="seg" id="preview-modes"><button type="button" data-pmode="summary">Summary</button><button type="button" data-pmode="json">JSON</button><button type="button" data-pmode="materialize">Materialize</button><button type="button" data-pmode="managed">Managed Agents</button></span><span class="exportwrap"><button id="exportBtn">Export ▾</button><div id="exportMenu" class="exportmenu" hidden><button id="dltar" class="menuitem" title="download the gem archive as a .tar.gz">⬇ Download .tar.gz</button><button id="save" class="menuitem" title="write the gem archive (manifest + lock + files) to a folder">💾 Save to folder…</button><button id="dl" class="menuitem">⬇ Download JSON</button><button id="copy" class="menuitem">⧉ Copy JSON</button></div></span><span class="d" id="archiveStatus" style="margin-left:6px"></span></div>
250
255
  <div id="preview"></div>
251
256
  <p class="note">MCP server &amp; hook config secrets are shown as <code>&lt;redacted&gt;</code> and never exported. Skill, CLAUDE.md, rules bodies &amp; hook commands are bundled as written — don't keep secrets in them.</p>
252
257
  <div id="checksPanel" class="group" style="margin-top:14px">
@@ -275,6 +280,8 @@
275
280
  <div class="modal">
276
281
  <div class="modal-h"><strong class="t">Open a testbed</strong><button id="recentClose" class="ghost" style="margin-left:auto">✕ Close</button></div>
277
282
  <div class="modal-body" style="padding:14px">
283
+ <div id="anView" hidden></div>
284
+ <div id="anPicker">
278
285
  <div id="tbCwd" hidden style="padding:12px;border:1px solid var(--line);border-radius:8px;margin-bottom:14px">
279
286
  <div style="margin-bottom:6px">This folder looks like a <span id="tbCwdFlavor"></span> project</div>
280
287
  <div class="d" id="tbCwdPath" style="margin-bottom:8px;word-break:break-all"></div>
@@ -289,8 +296,9 @@
289
296
  <div id="recentList">Loading…</div>
290
297
  <div class="src" style="margin:14px 0 6px">Discovered <span class="d">(projects from your Claude / Codex history)</span></div>
291
298
  <div id="discoveredList">Loading…</div>
299
+ </div>
292
300
  </div>
293
- <div class="modal-h" style="border-top:1px solid var(--line);border-bottom:0">
301
+ <div class="modal-h" id="anFooter" style="border-top:1px solid var(--line);border-bottom:0">
294
302
  <span class="d">Not this folder? Pick a recent one above, or browse.</span>
295
303
  <button id="recentBrowse" style="margin-left:auto">Browse folder…</button>
296
304
  </div>
@@ -328,6 +336,7 @@ let candidate = null; // { path, flavor } currently shown in the top block
328
336
 
329
337
  async function openOrCreateTestbed(){
330
338
  document.getElementById("recentModal").hidden = false;
339
+ showPicker(); // always open on the project list, not a stale recommendation view
331
340
  try {
332
341
  const s = await (await fetch("/api/testbed/suggestion")).json();
333
342
  if (s.looksLikeProject) renderCandidate(s.cwd, s.flavor, s.name);
@@ -381,11 +390,15 @@ async function renderRecents(){
381
390
  const fl = esc((FLAVORS[p.flavor]||FLAVORS.claude).label);
382
391
  const when = p.lastUsed ? esc(p.lastUsed.slice(0,10)) : "";
383
392
  const stale = p.exists ? "" : ` <span class="d" title="path no longer exists">· missing</span>`;
384
- return `<label class="row recent" data-i="${i}"${p.exists?"":' style="opacity:.5"'}><span><b>${short}</b> <span class="src">${fl}</span> <span class="d">${esc(p.path)}</span>${stale}</span><span class="d" style="margin-left:auto">${when}</span></label>`;
393
+ const an = p.exists ? `<button type="button" class="ghost anBtn" data-i="${i}" title="recommend a Gem from this project's session history">Analyze</button>` : "";
394
+ return `<label class="row recent" data-i="${i}"${p.exists?"":' style="opacity:.5"'}><span><b>${short}</b> <span class="src">${fl}</span> <span class="d">${esc(p.path)}</span>${stale}</span><span class="d" style="margin-left:auto">${when}</span>${an}</label>`;
385
395
  }).join("");
386
396
  list.querySelectorAll("label.recent").forEach(row=>{
387
397
  row.onclick = ()=>{ const p = recents[+row.dataset.i]; if(!p.exists) return; document.getElementById("recentModal").hidden = true; useTestbed(p.path, p.flavor, p.name); };
388
398
  });
399
+ list.querySelectorAll("button.anBtn").forEach(btn=>{
400
+ btn.onclick = (e)=>{ e.preventDefault(); e.stopPropagation(); const p = recents[+btn.dataset.i]; analyzeWorkflow(p.path, p.flavor, p.name); };
401
+ });
389
402
  }
390
403
 
391
404
  // Cross-repo projects from Claude/Codex session history, minus anything already in Recent.
@@ -404,11 +417,15 @@ async function renderDiscovered(){
404
417
  const fl = esc((FLAVORS[p.flavor]||FLAVORS.claude).label);
405
418
  const when = p.lastUsed ? esc(p.lastUsed.slice(0,10)) : "";
406
419
  const stale = p.exists ? "" : ` <span class="d" title="path no longer exists">· missing</span>`;
407
- return `<label class="row disc" data-i="${i}"${p.exists?"":' style="opacity:.5"'}><span><b>${short}</b> <span class="src">${fl}</span> <span class="d">${esc(p.path)}</span>${stale}</span><span class="d" style="margin-left:auto">${when}</span></label>`;
420
+ const an = p.exists ? `<button type="button" class="ghost anBtn" data-i="${i}" title="recommend a Gem from this project's session history">Analyze</button>` : "";
421
+ return `<label class="row disc" data-i="${i}"${p.exists?"":' style="opacity:.5"'}><span><b>${short}</b> <span class="src">${fl}</span> <span class="d">${esc(p.path)}</span>${stale}</span><span class="d" style="margin-left:auto">${when}</span>${an}</label>`;
408
422
  }).join("");
409
423
  list.querySelectorAll("label.disc").forEach(row=>{
410
424
  row.onclick = ()=>{ const p = projects[+row.dataset.i]; if(!p.exists) return; document.getElementById("recentModal").hidden = true; useTestbed(p.path, p.flavor, p.path.replace(/^.*\//, "")); };
411
425
  });
426
+ list.querySelectorAll("button.anBtn").forEach(btn=>{
427
+ btn.onclick = (e)=>{ e.preventDefault(); e.stopPropagation(); const p = projects[+btn.dataset.i]; analyzeWorkflow(p.path, p.flavor, p.path.replace(/^.*\//, "")); };
428
+ });
412
429
  }
413
430
 
414
431
  // Adopt a known project+flavor: scaffold is idempotent (writeIfAbsent) and records a recent.
@@ -419,12 +436,165 @@ async function useTestbed(path, flavor, name){
419
436
  setTestbed(path);
420
437
  nameEdited = false; // picking a project is explicit — default the gem name to it
421
438
  document.getElementById("name").value = name;
422
- load();
439
+ await load(); // awaited so callers (e.g. Analyze) can apply once the inventory is rendered
440
+ }
441
+
442
+ // Swap the picker for a focused recommendation view (same modal, no stacking).
443
+ function anModalTitle(t){ const el = document.querySelector("#recentModal .modal-h .t"); if (el) el.textContent = t; }
444
+ // NB: #anFooter has class `modal-h` whose CSS `display:flex` overrides the
445
+ // `hidden` attribute, so toggle its inline display directly.
446
+ function showAnView(){ document.getElementById("anPicker").hidden = true; document.getElementById("anFooter").style.display = "none"; document.getElementById("anView").hidden = false; anModalTitle("Workflow recommendation"); }
447
+ function showPicker(){ document.getElementById("anView").hidden = true; document.getElementById("anPicker").hidden = false; document.getElementById("anFooter").style.display = ""; anModalTitle("Open a testbed"); }
448
+
449
+ // Recommend a Gem for `path` from its session history — IN PLACE, without adopting
450
+ // it as the active testbed. Renders a focused view inside the same modal. One at a
451
+ // time: the focused view replaces the list, so analyses can't overlap from the UI.
452
+ function analyzeWorkflow(path, flavor, name, fresh){
453
+ const view = document.getElementById("anView");
454
+ let es = null, acc = "", finished = false;
455
+ view.innerHTML =
456
+ `<button type="button" class="ghost anBack">← back to projects</button>`
457
+ + `<div style="margin-top:10px"><b>${esc(name)}</b></div>`
458
+ + `<div class="d" id="anPhase" style="margin-top:8px">Starting…</div>`
459
+ + `<pre id="anStream" style="margin-top:8px;max-height:140px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:11px;color:var(--muted);background:rgba(127,127,127,.06);padding:8px;border-radius:6px;display:none"></pre>`;
460
+ view.querySelector(".anBack").onclick = (e) => { e.preventDefault(); finished = true; if (es) es.close(); showPicker(); };
461
+ showAnView();
462
+ const phaseEl = view.querySelector("#anPhase");
463
+ const streamEl = view.querySelector("#anStream");
464
+
465
+ es = new EventSource(`/api/workflow/analyze/stream?root=${encodeURIComponent(path)}${fresh ? "&fresh=1" : ""}`);
466
+ es.addEventListener("phase", (ev) => {
467
+ const d = JSON.parse(ev.data);
468
+ if (d.phase === "scanning") phaseEl.textContent = "Scanning session history…";
469
+ else if (d.phase === "scanned") phaseEl.textContent = `Scanned ${d.transcripts} transcript(s) · ${d.sessions} session(s). Asking Claude…`;
470
+ else if (d.phase === "thinking") phaseEl.textContent = "Claude is clustering a Gem from your usage…";
471
+ else if (d.phase === "validating") phaseEl.textContent = "Validating against your inventory…";
472
+ });
473
+ es.addEventListener("delta", (ev) => {
474
+ acc += JSON.parse(ev.data).text;
475
+ streamEl.style.display = "block";
476
+ streamEl.textContent = acc.slice(-1200); // tail of the agent's live output
477
+ streamEl.scrollTop = streamEl.scrollHeight;
478
+ });
479
+ es.addEventListener("done", (ev) => { finished = true; es.close(); renderAnView(view, path, flavor, name, JSON.parse(ev.data)); });
480
+ es.addEventListener("failed", (ev) => { finished = true; es.close(); renderAnError(view, esc(JSON.parse(ev.data).message || "unknown error")); });
481
+ es.onerror = () => { if (finished) return; finished = true; es.close(); renderAnError(view, "connection lost"); };
482
+ }
483
+
484
+ function renderAnError(view, msg){
485
+ view.innerHTML = `<button type="button" class="ghost anBack">← back to projects</button><div class="d" style="margin-top:10px">Analysis failed: ${msg}</div>`;
486
+ view.querySelector(".anBack").onclick = (e) => { e.preventDefault(); showPicker(); };
487
+ }
488
+
489
+ // Read-only view of the candidate Gems. Each card adopts the project AND applies
490
+ // just that candidate's selection.
491
+ let anCandidates = []; // stash so a card's button can grab its candidate
492
+ function renderAnView(view, path, flavor, name, res){
493
+ anCandidates = res.candidates || [];
494
+ const degraded = res.degraded ? ` <span class="d">(usage frequency — agent unavailable)</span>` : "";
495
+ const gaps = (res.gaps || []).length ? `<div class="d" style="margin:8px 0">Used but not in inventory: ${esc(res.gaps.join(", "))}</div>` : "";
496
+ const li = (n, t, reason, root) => {
497
+ const scope = root === null ? ` <span class="src" title="global / plugin artifact">global</span>` : "";
498
+ return `<li style="margin-bottom:4px"><b>${esc(n)}</b> <span class="d">${esc(t)}</span>${scope}${reason ? ` — ${esc(reason)}` : ""}</li>`;
499
+ };
500
+
501
+ const cards = anCandidates.map((c, i) => {
502
+ const items = c.include.map(it => li(it.name, it.type, it.reason, it.root)).join("");
503
+ const instr = c.includeInstructions ? li("CLAUDE.md", "instructions", "loaded every session", c.root) : "";
504
+ return `<div style="border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin-bottom:10px">`
505
+ + `<div style="font-size:14px"><b>${esc(c.name)}</b> <span class="d">· ${c.confidence}</span></div>`
506
+ + `<div class="d" style="margin:3px 0 8px">${esc(c.description)}</div>`
507
+ + (items || instr ? `<ul style="margin:0 0 8px 18px;padding:0">${items}${instr}</ul>` : `<div class="d">No project-scoped artifacts.</div>`)
508
+ + `<button type="button" class="anApply" data-i="${i}" style="margin-top:4px">Switch &amp; apply this Gem ▸</button>`
509
+ + `</div>`;
510
+ }).join("");
511
+
512
+ const heading = anCandidates.length > 1 ? `${anCandidates.length} candidate Gems` : (anCandidates.length === 1 ? "Recommended Gem" : "No recurring flows found");
513
+ const cached = res.cached ? ` <span class="src" title="cached — re-analyze to refresh">cached</span>` : "";
514
+ view.innerHTML =
515
+ `<div style="display:flex;align-items:center;gap:8px"><button type="button" class="ghost anBack">← back to projects</button>`
516
+ + `<button type="button" class="ghost anFresh" style="margin-left:auto" title="re-run the analysis ignoring the cache">↻ Re-analyze</button></div>`
517
+ + `<div style="margin-top:10px;font-size:15px"><b>${esc(heading)}</b>${degraded}${cached} <span class="d">· ${res.signalSummary.sessionsScanned} session(s)</span></div>`
518
+ + `<div class="d" style="margin:4px 0 10px;word-break:break-all">${esc(path)}</div>`
519
+ + (cards || `<div class="d">Nothing to recommend from this project's usage.</div>`)
520
+ + gaps;
521
+ view.querySelector(".anBack").onclick = (e) => { e.preventDefault(); showPicker(); };
522
+ view.querySelector(".anFresh").onclick = (e) => { e.preventDefault(); analyzeWorkflow(path, flavor, name, true); };
523
+ view.querySelectorAll(".anApply").forEach(btn => {
524
+ btn.onclick = (e) => { e.preventDefault(); switchAndApply(path, flavor, anCandidates[+btn.dataset.i]); };
525
+ });
526
+ }
527
+
528
+ // The explicit switch: adopt the project as the active testbed, then pre-check
529
+ // the chosen candidate's selection.
530
+ async function switchAndApply(path, flavor, candidate){
531
+ document.getElementById("recentModal").hidden = true;
532
+ showPicker(); // reset modal for next open
533
+ await useTestbed(path, flavor, candidate.name); // adopt + render this project's inventory
534
+ applyCandidate(path, candidate); // pre-check this candidate's boxes + banner
535
+ }
536
+
537
+ function ensureAnalyzeBanner(){
538
+ let b = document.getElementById("analyzeBanner");
539
+ if (!b) { b = document.createElement("div"); b.id = "analyzeBanner"; b.className = "note"; b.style.margin = "0 0 12px";
540
+ const inv = document.getElementById("inventory"); inv.insertBefore(b, inv.firstChild); }
541
+ return b;
542
+ }
543
+
544
+ // Mutate the shared `sel` model (same shape onToggle maintains) from one
545
+ // candidate's selection — global artifacts go top-level, project ones under
546
+ // projects[root] — then re-sync the checkboxes and the build preview.
547
+ function applyCandidate(root, candidate){
548
+ const s = candidate.selection;
549
+ // global (top-level) artifacts
550
+ (s.skills || []).forEach(n => sel.skills.add(n));
551
+ (s.mcpServers || []).forEach(n => sel.mcpServers.add(n));
552
+ (s.hooks || []).forEach(n => sel.hooks.add(n));
553
+ if (s.includeInstructions) sel.includeInstructions = true;
554
+ // project artifacts
555
+ const ps = (s.projects || {})[root] || {};
556
+ const ts = projSel(root);
557
+ (ps.skills || []).forEach(n => ts.skills.add(n));
558
+ (ps.mcpServers || []).forEach(n => ts.mcpServers.add(n));
559
+ (ps.hooks || []).forEach(n => ts.hooks.add(n));
560
+ if (ps.includeInstructions) ts.includeInstructions = true;
561
+
562
+ renderGlobalGroup(candidate); // surface the global picks as checkboxes
563
+ restoreChecks(); refresh();
564
+
565
+ const reasons = candidate.include.map(i => `${esc(i.name)}: ${esc(i.reason)}`).join(" · ");
566
+ ensureAnalyzeBanner().innerHTML = `<b>${esc(candidate.name)}</b> — ${esc(candidate.description)}<br>${reasons}`;
567
+ }
568
+
569
+ // The testbed inventory pane normally shows only project artifacts (globals are
570
+ // reached via Import). When a candidate bundles globals, render them as a
571
+ // dedicated checkbox group so they're visible and reviewable. Their data-kind is
572
+ // the top-level kind, so onToggle/restoreChecks/buildSelectionBody treat them as
573
+ // the global selection.
574
+ function renderGlobalGroup(candidate){
575
+ document.getElementById("globalGroup")?.remove();
576
+ const globals = (candidate.include || []).filter(i => i.root === null);
577
+ if (!globals.length) return;
578
+ const kindOf = t => t === "skill" ? "skills" : t === "mcp_server" ? "mcpServers" : "hooks";
579
+ const rows = globals.map(i =>
580
+ `<label class="row"><input type="checkbox" data-kind="${kindOf(i.type)}" data-name="${esc(i.name)}"> <span>${esc(i.name)} <span class="src">global</span> <span class="d">— ${esc(i.type)}</span></span></label>`).join("");
581
+ document.getElementById("inventory").insertAdjacentHTML("afterbegin",
582
+ `<div class="group" id="globalGroup"><h2>Global / plugin (recommended)</h2>${rows}</div>`);
583
+ document.querySelectorAll("#globalGroup input[type=checkbox]").forEach(cb => cb.addEventListener("change", onToggle));
584
+ }
585
+
586
+ // Prefer the Electron-native folder dialog when running in the desktop app;
587
+ // fall back to the local REST picker in a plain browser. Both return { path }.
588
+ async function pickFolderPath() {
589
+ if (window.agentgem && window.agentgem.pickFolder) {
590
+ return await window.agentgem.pickFolder();
591
+ }
592
+ return await (await fetch("/api/pick-folder")).json();
423
593
  }
424
594
 
425
595
  // Browse routes the picked folder back through the same confirm block (no prompts).
426
596
  async function browseForTestbed(){
427
- const pick = await (await fetch("/api/pick-folder")).json();
597
+ const pick = await pickFolderPath();
428
598
  if(!pick.path) return;
429
599
  const flavor = (await (await fetch(`/api/testbed/detect?root=${encodeURIComponent(pick.path)}`)).json()).flavor;
430
600
  renderCandidate(pick.path, flavor, pick.path.replace(/^.*\//, ""));
@@ -700,16 +870,19 @@ function renderPreview(){
700
870
  }
701
871
  document.querySelectorAll("#preview-modes button").forEach(b => b.classList.toggle("on", b.dataset.pmode === previewMode));
702
872
  }
873
+ let a2aServerMode = false; // a2a target: false = Agent Card only (default), true = also emit the runnable server
703
874
  async function renderMaterialize(){
704
875
  const el = document.getElementById("preview");
705
876
  const reqBody = buildSelectionBody();
706
877
  reqBody.target = document.getElementById("target").value;
878
+ if (reqBody.target === "a2a" && a2aServerMode) reqBody.a2aServer = true;
707
879
  const m = await (await fetch("/api/materialize", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(reqBody) })).json();
708
880
  window.__materialize = m;
709
881
  if (m.error) { el.innerHTML = ""; const pre = document.createElement("pre"); pre.className = "json"; pre.textContent = JSON.stringify(m, null, 2); el.appendChild(pre); return; }
710
882
  const paths = Object.keys(m.files || {});
711
883
  const compat = Object.entries(m.compatibility || {}).map(([t, c]) => `${t} ${c.skipped ? c.skipped + " skipped" : "✓"}`).join(" · ");
712
884
  let h = `<div class="psummary"><div class="phead"><strong>${esc(m.target)}</strong> <span class="d">· ${paths.length} file${paths.length === 1 ? "" : "s"}</span></div>`;
885
+ if (reqBody.target === "a2a") h += `<div class="pgroup"><label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="a2aServerToggle" ${a2aServerMode ? "checked" : ""}> Emit runnable server (AI SDK v7) — otherwise just the Agent Card</label></div>`;
713
886
  h += `<div class="pgroup"><h3>Files</h3>` + (paths.length ? paths.map(p => `<button type="button" class="prow" data-mpath="${esc(p)}"><span class="pn">${esc(p)}</span></button>`).join("") : `<p class="d">No files — select artifacts on the left.</p>`) + `</div>`;
714
887
  if ((m.skipped || []).length) h += `<div class="pgroup"><h3>Skipped (${m.skipped.length})</h3>` + m.skipped.map(s => `<div class="prow"><span class="pn">${esc(s.artifact)}</span> <span class="pm">${esc(s.reason)}</span></div>`).join("") + `</div>`;
715
888
  h += `<p class="note">Compatibility: ${esc(compat)}</p></div>`;
@@ -1018,6 +1191,10 @@ document.getElementById("target").addEventListener("change", (e) => {
1018
1191
  if (mapped) deployBackend = mapped;
1019
1192
  renderPreview();
1020
1193
  });
1194
+ // a2a server toggle (rendered inside the materialize preview): re-materialize with/without the server.
1195
+ document.getElementById("preview").addEventListener("change", (e) => {
1196
+ if (e.target.id === "a2aServerToggle") { a2aServerMode = e.target.checked; renderMaterialize(); }
1197
+ });
1021
1198
  document.getElementById("preview").addEventListener("click", e => {
1022
1199
  if (e.target.id === "publishBtn") { doPublish(); return; }
1023
1200
  const mp = e.target.closest("[data-mpath]");
@@ -1049,7 +1226,7 @@ document.getElementById("copy").addEventListener("click", () => navigator.clipbo
1049
1226
  document.getElementById("save").addEventListener("click", async () => {
1050
1227
  const status = document.getElementById("archiveStatus");
1051
1228
  status.textContent = "Choose a folder…";
1052
- const picked = await (await fetch("/api/pick-folder")).json();
1229
+ const picked = await pickFolderPath();
1053
1230
  if (!picked.path) { status.textContent = ""; return; }
1054
1231
  status.textContent = "Saving…";
1055
1232
  const r = await (await fetch("/api/archive", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...buildSelectionBody(), outDir: picked.path }) })).json();
package/dist/schemas.js CHANGED
@@ -178,6 +178,7 @@ export const MaterializeRequestSchema = z.object({
178
178
  name: z.string().optional(),
179
179
  dir: z.string().optional(),
180
180
  projects: z.array(z.string()).optional(),
181
+ a2aServer: z.boolean().optional(), // a2a target: also emit the runnable server, not just the Agent Card
181
182
  }).refine((d) => d.selection !== undefined || d.archivePath !== undefined, {
182
183
  message: "provide either selection or archivePath",
183
184
  });
@@ -236,6 +237,37 @@ export const PublishResultSchema = z.discriminatedUnion("kind", [ManagedAgentRes
236
237
  export const DirQuerySchema = z.object({ dir: z.string().optional(), projects: z.string().optional() });
237
238
  export const PickQuerySchema = z.object({});
238
239
  export const PickFolderSchema = z.object({ path: z.string().nullable() });
240
+ // ── Workflow-aware Gem recommendation ──
241
+ export const WorkflowAnalyzeRequestSchema = z.object({
242
+ dir: z.string().optional(), // .claude dir (resolveDirs handles the default)
243
+ root: z.string(), // the project root to analyze (one of the discovered cwds)
244
+ });
245
+ const RecommendedItemSchema = z.object({
246
+ type: z.enum(["skill", "mcp_server", "instructions", "hook"]),
247
+ name: z.string(),
248
+ reason: z.string(),
249
+ root: z.string().nullable(), // project root, or null for a global/plugin artifact
250
+ });
251
+ // One candidate Gem, carrying its own ready-to-POST GemSelection.
252
+ const GemCandidateSchema = z.object({
253
+ name: z.string(),
254
+ description: z.string(),
255
+ root: z.string(),
256
+ includeInstructions: z.boolean(),
257
+ include: z.array(RecommendedItemSchema),
258
+ confidence: z.enum(["high", "medium", "low"]),
259
+ selection: z.record(z.string(), z.unknown()), // a GemSelection; buildGem validates structurally at /api/gem
260
+ });
261
+ export const WorkflowAnalyzeResponseSchema = z.object({
262
+ candidates: z.array(GemCandidateSchema),
263
+ gaps: z.array(z.string()), // project-level: used but absent from inventory
264
+ signalSummary: z.object({
265
+ sessionsScanned: z.number(),
266
+ spanDays: z.number(),
267
+ notes: z.array(z.string()),
268
+ }),
269
+ degraded: z.boolean(),
270
+ });
239
271
  export const GemSchema = z.object({
240
272
  name: z.string(),
241
273
  createdFrom: z.string(),
@@ -0,0 +1,72 @@
1
+ // src/workflowStream.ts
2
+ //
3
+ // SSE endpoint for the workflow analysis. agentgem's decorator framework returns
4
+ // a single JSON body, so streaming progress (scan → agent token stream → done)
5
+ // is served by a raw Express handler registered on `server.expressApp`. The
6
+ // non-streaming POST /api/workflow/analyze stays for programmatic/test callers.
7
+ import { introspectConfig, introspectProject } from "./gem/introspect.js";
8
+ import { resolveDirs, resolveProject } from "./resolveDir.js";
9
+ import { claudeTranscriptsForCwd, scanWorkflow } from "./gem/workflowScan.js";
10
+ import { recommendWorkflow, recommendationToSelection } from "./gem/acpRecommender.js";
11
+ import { transcriptToken, readAnalysisCache, writeAnalysisCache } from "./gem/analysisCache.js";
12
+ export async function streamWorkflowAnalyze(req, res) {
13
+ const root = typeof req.query.root === "string" ? req.query.root : "";
14
+ const dir = typeof req.query.dir === "string" ? req.query.dir : undefined;
15
+ const fresh = req.query.fresh === "1"; // bypass the cache (Re-analyze)
16
+ res.writeHead(200, {
17
+ "Content-Type": "text/event-stream",
18
+ "Cache-Control": "no-cache, no-transform",
19
+ Connection: "keep-alive",
20
+ "X-Accel-Buffering": "no", // disable proxy buffering so events flush immediately
21
+ });
22
+ const send = (event, data) => {
23
+ res.write(`event: ${event}\n`);
24
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
25
+ };
26
+ try {
27
+ if (!root) {
28
+ send("failed", { message: "missing root" });
29
+ return;
30
+ }
31
+ const dirs = resolveDirs(dir);
32
+ const project = introspectProject(resolveProject(root));
33
+ const globalInv = introspectConfig(dirs); // global + plugin artifacts
34
+ const scanInv = { project, global: { skills: globalInv.skills, mcpServers: globalInv.mcpServers, hooks: globalInv.hooks } };
35
+ send("phase", { phase: "scanning" });
36
+ const paths = claudeTranscriptsForCwd(dirs.claudeDir, root);
37
+ // Cache hit (unless Re-analyze): return the prior result instantly so the
38
+ // user can revisit a project to pick another candidate without re-running
39
+ // the agent. Token invalidates when sessions are added/updated.
40
+ const token = transcriptToken(paths);
41
+ if (!fresh) {
42
+ const cached = readAnalysisCache(root, token);
43
+ if (cached) {
44
+ send("done", { ...cached, cached: true });
45
+ return;
46
+ }
47
+ }
48
+ const signal = scanWorkflow(paths, scanInv);
49
+ send("phase", { phase: "scanned", transcripts: paths.length, sessions: signal.sessions.scanned });
50
+ send("phase", { phase: "thinking" });
51
+ const { analysis, degraded } = await recommendWorkflow(signal, scanInv, {
52
+ onDelta: (chunk) => send("delta", { text: chunk }),
53
+ });
54
+ send("phase", { phase: "validating" });
55
+ const candidates = analysis.candidates.map((c) => ({ ...c, selection: recommendationToSelection(c) }));
56
+ const payload = {
57
+ candidates,
58
+ gaps: analysis.gaps,
59
+ signalSummary: { sessionsScanned: signal.sessions.scanned, spanDays: signal.sessions.spanDays, notes: signal.notes },
60
+ degraded,
61
+ };
62
+ if (!degraded)
63
+ writeAnalysisCache(root, token, payload, Date.now()); // don't cache fallbacks
64
+ send("done", { ...payload, cached: false });
65
+ }
66
+ catch (err) {
67
+ send("failed", { message: err?.message ?? String(err) });
68
+ }
69
+ finally {
70
+ res.end();
71
+ }
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninemind/agentgem",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "A local web UI that introspects your coding-agent config, redacts secrets at capture, and builds a portable, composable Gem.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -54,6 +54,7 @@
54
54
  "@agentback/openapi": "^0.5.2",
55
55
  "@agentback/rest": "^0.5.2",
56
56
  "@agentback/rest-explorer": "^0.5.2",
57
+ "@agentclientprotocol/sdk": "^0.28.1",
57
58
  "@anthropic-ai/sdk": "^0.104.2",
58
59
  "@aws-sdk/client-bedrock-agentcore-control": "^3.1073.0",
59
60
  "dotenv": "^17.4.2",