@ninemind/agentgem 0.1.0 → 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
@@ -3,9 +3,10 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
+ <a href="https://www.npmjs.com/package/@ninemind/agentgem"><img src="https://img.shields.io/npm/v/%40ninemind%2Fagentgem?color=9a3324&label=npm" alt="npm version"></a>
6
7
  <a href="https://github.com/ninemindai/agentgem/actions/workflows/ci.yml"><img src="https://github.com/ninemindai/agentgem/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
7
8
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-9a3324" alt="MIT license"></a>
8
- <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-LTS-1f6b4f" alt="Node LTS"></a>
9
+ <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/%40ninemind%2Fagentgem?color=1f6b4f" alt="Node version"></a>
9
10
  <a href="https://agentback.dev"><img src="https://img.shields.io/badge/built_on-AgentBack-b08436" alt="Built on AgentBack"></a>
10
11
  <a href="docs/concepts.md"><img src="https://img.shields.io/badge/MCP-native-211c15" alt="MCP-native"></a>
11
12
  </p>
@@ -36,29 +37,32 @@ call exactly the same thing.
36
37
  re-reading raw config.
37
38
  - **Composition** — the manifest/lock split lets small, focused Gems be reconciled into
38
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.
39
43
  - **Deploy targets** — Eve and OpenAI Sandbox (code-gen), Flue (materialize, deployable to
40
44
  Cloudflare), and Bedrock AgentCore (managed backend); code-gen targets share a common
41
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.
42
50
  - **A GitHub-backed registry** — publish, resolve, merge, and install composable Gems over
43
51
  the same archive format.
44
52
  - **An agent-native path** — every operation is also an MCP tool, so your local agent can
45
53
  build Gems over `/mcp` with no browser involved.
46
54
 
47
- ## Usage
55
+ ## Quickstart
48
56
 
49
- Requires Node.js ≥ 22 and a coding-agent config at `~/.claude`.
57
+ Needs Node.js ≥ 22. From the directory of the agent project you want to package,
58
+ run it without installing:
50
59
 
51
60
  ```bash
52
- npx @ninemind/agentgem # run without installing
53
- # or install the `agentgem` command globally:
54
- npm install -g @ninemind/agentgem
55
- agentgem # → http://127.0.0.1:4317
56
- agentgem --port 8080 # override the port (also honors $PORT)
61
+ npx @ninemind/agentgem # npm
62
+ pnpm dlx @ninemind/agentgem # pnpm
57
63
  ```
58
64
 
59
- The server starts on `127.0.0.1` (default port `4317`) and prints:
60
-
61
- ```
65
+ ```text
62
66
  agentgem listening at http://127.0.0.1:4317
63
67
  UI: http://127.0.0.1:4317/
64
68
  API: http://127.0.0.1:4317/api/inventory · POST http://127.0.0.1:4317/api/gem
@@ -66,33 +70,63 @@ agentgem listening at http://127.0.0.1:4317
66
70
  MCP: http://127.0.0.1:4317/mcp
67
71
  ```
68
72
 
73
+ Open **<http://127.0.0.1:4317/>**, then:
74
+
75
+ 1. **Open a testbed** — click *Create / open testbed…*. AgentGem detects the project
76
+ you launched from (it has a `.claude`/`.codex`) and also lists ones from your
77
+ Claude/Codex session history. Pick it and click *Use this*.
78
+ 2. **Pick artifacts** — the project's skills / MCP servers / `CLAUDE.md` show on the
79
+ left; *Import from machine…* pulls in global ones. Tick what you want, name the Gem.
80
+ 3. **Watch it seal** — the live `gem.json` renders with every secret as `<redacted>`.
81
+ Download it — that archive is what every target and the registry consume.
82
+
83
+ <p align="center">
84
+ <img src="docs/screenshot.png" alt="The AgentGem Gem Builder: selected skills and MCP servers on the left, the live gem.json on the right with every secret shown as <redacted>" width="100%">
85
+ </p>
86
+
87
+ Prefer a persistent command? Install it globally:
88
+
89
+ ```bash
90
+ npm install -g @ninemind/agentgem # npm
91
+ pnpm add -g @ninemind/agentgem # pnpm
92
+ agentgem --port 8080 # honors $PORT; append ?dir=/path/to/.claude for another config
93
+ ```
94
+
69
95
  | Path | What it is |
70
96
  | ----------- | ------------------------------------------------------- |
71
97
  | `/` | The Gem Builder web UI |
72
98
  | `/explorer` | Swagger UI for the REST API (from the OpenAPI document) |
73
99
  | `/mcp` | The MCP endpoint — the same contract, for your agent |
74
100
 
75
- Open `/`, tick the skills / MCP servers / `CLAUDE.md` you want, name the Gem, and watch
76
- the live `gem.json` render with secrets already shown as `<redacted>`. Download it — that
77
- archive is what every target and the registry consume.
78
-
79
- Append `?dir=/path/to/.claude` to introspect a config directory other than the
80
- default `~/.claude`.
81
-
82
101
  ### From source
83
102
 
84
- To hack on AgentGem, clone the repo and use [pnpm](https://pnpm.io/) (AgentBack
85
- uses legacy decorators, so it builds with `tsc`, then runs `dist/`):
103
+ To hack on AgentGem, clone the repo. It's a [pnpm](https://pnpm.io/) project
104
+ (`npm` works too), and AgentBack uses legacy decorators, so it builds with `tsc`
105
+ then runs `dist/`:
86
106
 
87
107
  ```bash
88
- pnpm install
89
- pnpm dev # build + start in one step (→ node dist/index.js)
90
- pnpm test # tsc -b && vitest run — tests run against compiled dist/
91
- pnpm clean # rm -rf dist *.tsbuildinfo (run before testing after renames/moves)
108
+ pnpm install # or: npm install
109
+ pnpm dev # or: npm run dev — build + start in one step
110
+ pnpm test # or: npm test — tsc -b && vitest run, against compiled dist/
111
+ pnpm clean # or: npm run clean — rm -rf dist *.tsbuildinfo (run before re-testing after moves)
92
112
  ```
93
113
 
94
114
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow.
95
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
+
96
130
  ## Layering
97
131
 
98
132
  Depends on AgentBack: `@agentback/core` (lifecycle), `@agentback/rest` +
@@ -103,8 +137,11 @@ API, and the MCP endpoint are three boundaries over one set of Zod contracts —
103
137
 
104
138
  For deeper reference, see [`docs/`](docs/index.md):
105
139
  [getting started](docs/getting-started.md) ·
140
+ [desktop app](docs/desktop.md) ·
141
+ [analyze](docs/analyze.md) ·
106
142
  [concepts](docs/concepts.md) ·
107
143
  [targets & deploy](docs/targets.md) ·
144
+ [A2A](docs/a2a.md) ·
108
145
  [registry](docs/registry.md).
109
146
 
110
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,7 +2,13 @@
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>
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>" />
6
12
  <link rel="preconnect" href="https://fonts.googleapis.com">
7
13
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
8
14
  <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
@@ -201,7 +207,7 @@
201
207
  </nav>
202
208
  <main>
203
209
  <section class="pane left">
204
- <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>
205
211
  <div class="bar" id="wsBar" style="display:none">
206
212
  <select id="wsSelect" title="open a saved workspace" style="flex:1"><option value="">— no workspace —</option></select>
207
213
  <button id="wsNew" class="ghost" title="save the current selection as a new workspace">New workspace…</button>
@@ -245,7 +251,7 @@
245
251
  </div>
246
252
  </section>
247
253
  <section class="pane right">
248
- <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>
249
255
  <div id="preview"></div>
250
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>
251
257
  <div id="checksPanel" class="group" style="margin-top:14px">
@@ -274,6 +280,8 @@
274
280
  <div class="modal">
275
281
  <div class="modal-h"><strong class="t">Open a testbed</strong><button id="recentClose" class="ghost" style="margin-left:auto">✕ Close</button></div>
276
282
  <div class="modal-body" style="padding:14px">
283
+ <div id="anView" hidden></div>
284
+ <div id="anPicker">
277
285
  <div id="tbCwd" hidden style="padding:12px;border:1px solid var(--line);border-radius:8px;margin-bottom:14px">
278
286
  <div style="margin-bottom:6px">This folder looks like a <span id="tbCwdFlavor"></span> project</div>
279
287
  <div class="d" id="tbCwdPath" style="margin-bottom:8px;word-break:break-all"></div>
@@ -288,8 +296,9 @@
288
296
  <div id="recentList">Loading…</div>
289
297
  <div class="src" style="margin:14px 0 6px">Discovered <span class="d">(projects from your Claude / Codex history)</span></div>
290
298
  <div id="discoveredList">Loading…</div>
299
+ </div>
291
300
  </div>
292
- <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">
293
302
  <span class="d">Not this folder? Pick a recent one above, or browse.</span>
294
303
  <button id="recentBrowse" style="margin-left:auto">Browse folder…</button>
295
304
  </div>
@@ -327,6 +336,7 @@ let candidate = null; // { path, flavor } currently shown in the top block
327
336
 
328
337
  async function openOrCreateTestbed(){
329
338
  document.getElementById("recentModal").hidden = false;
339
+ showPicker(); // always open on the project list, not a stale recommendation view
330
340
  try {
331
341
  const s = await (await fetch("/api/testbed/suggestion")).json();
332
342
  if (s.looksLikeProject) renderCandidate(s.cwd, s.flavor, s.name);
@@ -380,11 +390,15 @@ async function renderRecents(){
380
390
  const fl = esc((FLAVORS[p.flavor]||FLAVORS.claude).label);
381
391
  const when = p.lastUsed ? esc(p.lastUsed.slice(0,10)) : "";
382
392
  const stale = p.exists ? "" : ` <span class="d" title="path no longer exists">· missing</span>`;
383
- 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>`;
384
395
  }).join("");
385
396
  list.querySelectorAll("label.recent").forEach(row=>{
386
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); };
387
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
+ });
388
402
  }
389
403
 
390
404
  // Cross-repo projects from Claude/Codex session history, minus anything already in Recent.
@@ -403,11 +417,15 @@ async function renderDiscovered(){
403
417
  const fl = esc((FLAVORS[p.flavor]||FLAVORS.claude).label);
404
418
  const when = p.lastUsed ? esc(p.lastUsed.slice(0,10)) : "";
405
419
  const stale = p.exists ? "" : ` <span class="d" title="path no longer exists">· missing</span>`;
406
- 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>`;
407
422
  }).join("");
408
423
  list.querySelectorAll("label.disc").forEach(row=>{
409
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(/^.*\//, "")); };
410
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
+ });
411
429
  }
412
430
 
413
431
  // Adopt a known project+flavor: scaffold is idempotent (writeIfAbsent) and records a recent.
@@ -418,12 +436,165 @@ async function useTestbed(path, flavor, name){
418
436
  setTestbed(path);
419
437
  nameEdited = false; // picking a project is explicit — default the gem name to it
420
438
  document.getElementById("name").value = name;
421
- 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();
422
593
  }
423
594
 
424
595
  // Browse routes the picked folder back through the same confirm block (no prompts).
425
596
  async function browseForTestbed(){
426
- const pick = await (await fetch("/api/pick-folder")).json();
597
+ const pick = await pickFolderPath();
427
598
  if(!pick.path) return;
428
599
  const flavor = (await (await fetch(`/api/testbed/detect?root=${encodeURIComponent(pick.path)}`)).json()).flavor;
429
600
  renderCandidate(pick.path, flavor, pick.path.replace(/^.*\//, ""));
@@ -699,16 +870,19 @@ function renderPreview(){
699
870
  }
700
871
  document.querySelectorAll("#preview-modes button").forEach(b => b.classList.toggle("on", b.dataset.pmode === previewMode));
701
872
  }
873
+ let a2aServerMode = false; // a2a target: false = Agent Card only (default), true = also emit the runnable server
702
874
  async function renderMaterialize(){
703
875
  const el = document.getElementById("preview");
704
876
  const reqBody = buildSelectionBody();
705
877
  reqBody.target = document.getElementById("target").value;
878
+ if (reqBody.target === "a2a" && a2aServerMode) reqBody.a2aServer = true;
706
879
  const m = await (await fetch("/api/materialize", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(reqBody) })).json();
707
880
  window.__materialize = m;
708
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; }
709
882
  const paths = Object.keys(m.files || {});
710
883
  const compat = Object.entries(m.compatibility || {}).map(([t, c]) => `${t} ${c.skipped ? c.skipped + " skipped" : "✓"}`).join(" · ");
711
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>`;
712
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>`;
713
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>`;
714
888
  h += `<p class="note">Compatibility: ${esc(compat)}</p></div>`;
@@ -1017,6 +1191,10 @@ document.getElementById("target").addEventListener("change", (e) => {
1017
1191
  if (mapped) deployBackend = mapped;
1018
1192
  renderPreview();
1019
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
+ });
1020
1198
  document.getElementById("preview").addEventListener("click", e => {
1021
1199
  if (e.target.id === "publishBtn") { doPublish(); return; }
1022
1200
  const mp = e.target.closest("[data-mpath]");
@@ -1048,7 +1226,7 @@ document.getElementById("copy").addEventListener("click", () => navigator.clipbo
1048
1226
  document.getElementById("save").addEventListener("click", async () => {
1049
1227
  const status = document.getElementById("archiveStatus");
1050
1228
  status.textContent = "Choose a folder…";
1051
- const picked = await (await fetch("/api/pick-folder")).json();
1229
+ const picked = await pickFolderPath();
1052
1230
  if (!picked.path) { status.textContent = ""; return; }
1053
1231
  status.textContent = "Saving…";
1054
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.0",
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",