@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 +61 -24
- package/dist/gem/acpRecommender.js +303 -0
- package/dist/gem/analysisCache.js +51 -0
- package/dist/gem/targets.js +218 -0
- package/dist/gem/testbedFlavors.js +1 -0
- package/dist/gem/workflowScan.js +0 -0
- package/dist/gem.controller.js +33 -2
- package/dist/index.js +5 -0
- package/dist/public/index.html +186 -8
- package/dist/schemas.js +32 -0
- package/dist/workflowStream.js +72 -0
- package/package.json +2 -1
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/
|
|
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
|
-
##
|
|
55
|
+
## Quickstart
|
|
48
56
|
|
|
49
|
-
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
85
|
-
uses legacy decorators, so it builds with `tsc
|
|
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
|
|
90
|
-
pnpm test
|
|
91
|
-
pnpm clean
|
|
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
|
+
}
|
package/dist/gem/targets.js
CHANGED
|
@@ -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
|
package/dist/gem.controller.js
CHANGED
|
@@ -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
|
package/dist/public/index.html
CHANGED
|
@@ -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 & hook config secrets are shown as <code><redacted></code> and never exported. Skill, CLAUDE.md, rules bodies & 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
|
-
|
|
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
|
-
|
|
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 & 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 (
|
|
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 (
|
|
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.
|
|
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",
|