@ninemind/agentgem 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/gem/acpRecommender.js +19 -63
- package/dist/gem/acpRun.js +156 -0
- package/dist/gem/acpSession.js +79 -0
- package/dist/gem/analysisCache.js +6 -2
- package/dist/gem/archive.js +17 -0
- package/dist/gem/binPath.js +9 -0
- package/dist/gem/buildGem.js +4 -1
- package/dist/gem/channels.js +29 -0
- package/dist/gem/credentials.js +3 -2
- package/dist/gem/distill.js +162 -0
- package/dist/gem/draftStage.js +77 -0
- package/dist/gem/gemVerify.js +35 -0
- package/dist/gem/inputError.js +21 -0
- package/dist/gem/registry.js +23 -4
- package/dist/gem/runGem.js +161 -0
- package/dist/gem/safeFetch.js +112 -0
- package/dist/gem/sandbox.js +37 -0
- package/dist/gem/sandboxLaunch.js +55 -0
- package/dist/gem/scrub.js +108 -0
- package/dist/gem/search.js +34 -0
- package/dist/gem/share.js +21 -0
- package/dist/gem/targets.js +85 -39
- package/dist/gem/workflowScan.js +0 -0
- package/dist/gem/workspaces.js +4 -3
- package/dist/gem.controller.js +121 -17
- package/dist/gem.tools.js +53 -5
- package/dist/gemRunStream.js +67 -0
- package/dist/index.js +12 -2
- package/dist/originGuard.js +36 -0
- package/dist/public/index.html +261 -4
- package/dist/schemas.js +149 -8
- package/dist/workflowStream.js +10 -4
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -39,7 +39,9 @@ call exactly the same thing.
|
|
|
39
39
|
larger agents with a single re-resolved lock, not a pile of overlapping config.
|
|
40
40
|
- **Workflow-aware recommendations** — [Analyze](docs/analyze.md) scans your agent's
|
|
41
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.
|
|
42
|
+
suggests ready-to-build Gems grouped by recurring workflow. It also **distills brand-new
|
|
43
|
+
draft skills** from the procedures you repeat by hand — review them and fold them
|
|
44
|
+
straight into a Gem.
|
|
43
45
|
- **Deploy targets** — Eve and OpenAI Sandbox (code-gen), Flue (materialize, deployable to
|
|
44
46
|
Cloudflare), and Bedrock AgentCore (managed backend); code-gen targets share a common
|
|
45
47
|
`compose` step.
|
|
@@ -5,11 +5,9 @@
|
|
|
5
5
|
// cluster/name/justify a Gem. The agent only ranks and explains — its output is
|
|
6
6
|
// re-validated against the inventory (the source of truth), and any failure
|
|
7
7
|
// degrades to a deterministic frequency-based recommendation. Never throws.
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
9
|
-
import { mkdirSync } from "node:fs";
|
|
10
8
|
import { join } from "node:path";
|
|
11
|
-
import { Readable, Writable } from "node:stream";
|
|
12
9
|
import { agentgemHome } from "../resolveDir.js";
|
|
10
|
+
import { connectAcpAdapter } from "./acpSession.js";
|
|
13
11
|
// Instructions are a boolean on ProjectSelection, not a named include.
|
|
14
12
|
const SELECTABLE = ["skill", "mcp_server", "hook"];
|
|
15
13
|
// Pinned Claude ACP adapter (npm: @agentclientprotocol/claude-agent-acp).
|
|
@@ -21,8 +19,10 @@ export const CLAUDE_AGENT = { id: "claude-code", name: "Claude Code", command: [
|
|
|
21
19
|
// over the JSON brief, so its cwd is irrelevant to the result.
|
|
22
20
|
export function analysisWorkspace() { return join(agentgemHome(), ".agentgem", "analysis"); }
|
|
23
21
|
let testConnectFn = null;
|
|
24
|
-
/** Test-only seam: route recommendWorkflow through an in-process fake agent. */
|
|
22
|
+
/** Test-only seam: route recommendWorkflow + distillWorkflow through an in-process fake agent. */
|
|
25
23
|
export function setConnectFnForTests(fn) { testConnectFn = fn; }
|
|
24
|
+
/** The active test connect fn (or null). distillWorkflow shares this seam. */
|
|
25
|
+
export function currentTestConnectFn() { return testConnectFn; }
|
|
26
26
|
// ── Deterministic analysis (fallback + the agent's baseline) ─────────────────
|
|
27
27
|
// One frequency-based candidate. Multi-candidate splitting is the agent's value-add;
|
|
28
28
|
// the deterministic fallback stays a single coherent Gem.
|
|
@@ -49,7 +49,7 @@ export function deterministicAnalysis(signal) {
|
|
|
49
49
|
include,
|
|
50
50
|
confidence: "medium",
|
|
51
51
|
}] : [];
|
|
52
|
-
return { candidates, gaps };
|
|
52
|
+
return { candidates, gaps, distilled: [] };
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
55
|
* Map a validated candidate to a GemSelection. Global artifacts (root===null)
|
|
@@ -154,7 +154,7 @@ export function validateAnalysis(raw, inv, signal) {
|
|
|
154
154
|
if (!candidates.length)
|
|
155
155
|
return fallback;
|
|
156
156
|
const gaps = Array.isArray(obj.gaps) ? obj.gaps.filter((g) => typeof g === "string") : fallback.gaps;
|
|
157
|
-
return { candidates, gaps };
|
|
157
|
+
return { candidates, gaps, distilled: [] };
|
|
158
158
|
}
|
|
159
159
|
// ── The agent run ────────────────────────────────────────────────────────────
|
|
160
160
|
const GROUNDING = (signalJson, inventoryJson) => `You recommend reusable "Gems" — bundles of installed artifacts for a recurring workflow.\n` +
|
|
@@ -226,78 +226,34 @@ export async function recommendWorkflow(signal, inv, opts = {}) {
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
/**
|
|
229
|
-
* Real connect:
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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.
|
|
229
|
+
* Real connect: route through the shared adapter plumbing in plan mode with
|
|
230
|
+
* permissions auto-denied (the recommender must never run tools), aggregating
|
|
231
|
+
* only the agent's message text into a string.
|
|
235
232
|
*/
|
|
236
233
|
export const defaultConnectFn = async (descriptor) => {
|
|
237
|
-
const
|
|
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;
|
|
234
|
+
const raw = await connectAcpAdapter(descriptor, { clientName: "agentgem-workflow-recommender", permission: "deny" });
|
|
251
235
|
const ctx = {
|
|
252
236
|
async open(cwd) {
|
|
253
|
-
|
|
254
|
-
mkdirSync(cwd, { recursive: true });
|
|
255
|
-
}
|
|
256
|
-
catch { /* best-effort */ }
|
|
257
|
-
const session = await agentCtx.buildSession(cwd).start();
|
|
258
|
-
const sessionId = session.sessionId;
|
|
237
|
+
const session = await raw.open(cwd);
|
|
259
238
|
return {
|
|
260
|
-
|
|
261
|
-
try {
|
|
262
|
-
await agentCtx.request("session/set_mode", { sessionId, modeId: mode });
|
|
263
|
-
}
|
|
264
|
-
catch { /* best-effort */ }
|
|
265
|
-
},
|
|
239
|
+
setMode: (mode) => session.setMode(mode),
|
|
266
240
|
async promptText(text, onDelta) {
|
|
267
241
|
let out = "";
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
break;
|
|
273
|
-
if (msg.kind === "session_update" && msg.update?.sessionUpdate === "agent_message_chunk") {
|
|
274
|
-
const block = msg.update.content;
|
|
242
|
+
await session.prompt(text, (u) => {
|
|
243
|
+
const update = u;
|
|
244
|
+
if (update?.sessionUpdate === "agent_message_chunk") {
|
|
245
|
+
const block = update.content;
|
|
275
246
|
if (block?.type === "text" && typeof block.text === "string") {
|
|
276
247
|
out += block.text;
|
|
277
248
|
onDelta?.(block.text);
|
|
278
249
|
}
|
|
279
250
|
}
|
|
280
|
-
}
|
|
251
|
+
});
|
|
281
252
|
return out;
|
|
282
253
|
},
|
|
283
|
-
dispose()
|
|
284
|
-
session.dispose?.();
|
|
285
|
-
}
|
|
286
|
-
catch { /* ignore */ } },
|
|
254
|
+
dispose: () => session.dispose(),
|
|
287
255
|
};
|
|
288
256
|
},
|
|
289
257
|
};
|
|
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
|
-
};
|
|
258
|
+
return { ctx, close: raw.close };
|
|
303
259
|
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// src/gem/acpRun.ts
|
|
2
|
+
//
|
|
3
|
+
// Runs a materialized Gem by driving a locally-installed ACP coding agent (Claude)
|
|
4
|
+
// against a task, and captures what the agent DID — its message text plus the
|
|
5
|
+
// trace of tool invocations. This is the trust-inversion of acpRecommender:
|
|
6
|
+
// recommender runner (here)
|
|
7
|
+
// ─────────── ─────────────
|
|
8
|
+
// neutral analysisWorkspace() → the materialized testbed dir
|
|
9
|
+
// mode "plan" (never edits) → a tool-capable mode (agent uses the Gem)
|
|
10
|
+
// captures agent_message_chunk → also captures tool_call updates
|
|
11
|
+
//
|
|
12
|
+
// As with the recommender, the SDK details live behind a single connectFn seam so
|
|
13
|
+
// tests inject a plain fake. Unlike the recommender there is no deterministic
|
|
14
|
+
// fallback — a failed run is a real outcome the caller (e.g. verification) needs,
|
|
15
|
+
// so we never throw: failures surface as { ok:false, error }.
|
|
16
|
+
//
|
|
17
|
+
// NOTE (consolidation): the ACP façade is duplicated from acpRecommender on purpose
|
|
18
|
+
// while this path is prototyped. Once both are proven, the two connectFns should be
|
|
19
|
+
// unified into a shared acpSession module.
|
|
20
|
+
import { connectAcpAdapter } from "./acpSession.js";
|
|
21
|
+
import { selectRunBackend, envPermission } from "./sandbox.js"; // values used at call-time (safe ESM cycle)
|
|
22
|
+
export function createAccumulator() {
|
|
23
|
+
return { text: "", toolCalls: [] };
|
|
24
|
+
}
|
|
25
|
+
export function applyUpdate(acc, update, handlers) {
|
|
26
|
+
switch (update.sessionUpdate) {
|
|
27
|
+
case "agent_message_chunk": {
|
|
28
|
+
const block = update.content;
|
|
29
|
+
if (block?.type === "text" && typeof block.text === "string") {
|
|
30
|
+
acc.text += block.text;
|
|
31
|
+
handlers?.onDelta?.(block.text);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
case "tool_call": {
|
|
36
|
+
if (!update.toolCallId)
|
|
37
|
+
return;
|
|
38
|
+
const tool = {
|
|
39
|
+
toolCallId: update.toolCallId,
|
|
40
|
+
title: update.title ?? "",
|
|
41
|
+
kind: update.kind,
|
|
42
|
+
status: update.status,
|
|
43
|
+
};
|
|
44
|
+
acc.toolCalls.push(tool);
|
|
45
|
+
handlers?.onToolCall?.(tool); // fires once, on start — final status lands in the result
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
case "tool_call_update": {
|
|
49
|
+
const existing = acc.toolCalls.find((t) => t.toolCallId === update.toolCallId);
|
|
50
|
+
if (!existing)
|
|
51
|
+
return; // update for a tool we never saw start — ignore
|
|
52
|
+
if (update.status !== undefined)
|
|
53
|
+
existing.status = update.status;
|
|
54
|
+
if (update.kind !== undefined)
|
|
55
|
+
existing.kind = update.kind;
|
|
56
|
+
if (update.title !== undefined && update.title !== "")
|
|
57
|
+
existing.title = update.title;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
default:
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Pinned Claude ACP adapter, same binary the recommender spawns.
|
|
65
|
+
export const CLAUDE_RUN_AGENT = { id: "claude-code", name: "Claude Code", command: ["claude-agent-acp"] };
|
|
66
|
+
// The default non-plan mode: lets the agent actually invoke the Gem's tools. The
|
|
67
|
+
// recommender pins "plan"; the runner pins its counterpart so the trust-inversion
|
|
68
|
+
// is explicit rather than incidental.
|
|
69
|
+
export const DEFAULT_RUN_MODE = "default";
|
|
70
|
+
// Default prompt timeout. Generous — a real Gem run can drive the agent through
|
|
71
|
+
// several tool calls — but bounded so a wedged agent can't hang the caller.
|
|
72
|
+
export const DEFAULT_RUN_TIMEOUT_MS = 300_000;
|
|
73
|
+
function withTimeout(p, ms) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const timer = setTimeout(() => reject(new Error(`agent run timed out after ${ms}ms`)), ms);
|
|
76
|
+
p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// Test seam: route runGemWithAgent through an in-process fake agent (mirrors
|
|
80
|
+
// acpRecommender.setConnectFnForTests). Lets the REST/SSE surface be exercised
|
|
81
|
+
// without spawning a real coding agent.
|
|
82
|
+
let testConnectFn = null;
|
|
83
|
+
export function setRunConnectFnForTests(fn) { testConnectFn = fn; }
|
|
84
|
+
/** True when a fake agent is injected — callers skip adapter resolution/fetch. */
|
|
85
|
+
export function hasTestConnectFn() { return testConnectFn !== null; }
|
|
86
|
+
/**
|
|
87
|
+
* Drive a local ACP agent against `task` inside the already-materialized `dir`,
|
|
88
|
+
* returning what it did. Never throws — connection/spawn failures come back as
|
|
89
|
+
* { ok:false, error }.
|
|
90
|
+
*/
|
|
91
|
+
export async function runGemWithAgent(opts) {
|
|
92
|
+
const explicit = opts.connectFn ?? testConnectFn;
|
|
93
|
+
const selected = explicit ? null : selectRunBackend(opts.dir);
|
|
94
|
+
const connectFn = explicit ?? selected.connectFn;
|
|
95
|
+
const sandbox = selected
|
|
96
|
+
? { backend: selected.backend.id, isolated: selected.backend.isolated }
|
|
97
|
+
: { backend: "injected", isolated: false };
|
|
98
|
+
const mode = opts.mode ?? DEFAULT_RUN_MODE;
|
|
99
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
|
|
100
|
+
let conn = null;
|
|
101
|
+
let handle = null;
|
|
102
|
+
try {
|
|
103
|
+
conn = await connectFn(opts.descriptor ?? CLAUDE_RUN_AGENT, null);
|
|
104
|
+
handle = await conn.ctx.open(opts.dir); // the testbed dir — NOT a neutral one
|
|
105
|
+
await handle.setMode(mode); // tool-capable — the agent uses the Gem
|
|
106
|
+
const result = await withTimeout(handle.prompt(opts.task, opts.onDelta, opts.onToolCall), timeoutMs);
|
|
107
|
+
return { ok: true, result, sandbox };
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
return { ok: false, result: { text: "", toolCalls: [] }, error: err.message, sandbox };
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
try {
|
|
114
|
+
handle?.dispose();
|
|
115
|
+
}
|
|
116
|
+
catch { /* ignore */ }
|
|
117
|
+
try {
|
|
118
|
+
conn?.close();
|
|
119
|
+
}
|
|
120
|
+
catch { /* ignore */ }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The shared run-session façade: connect the ACP adapter with an explicit permission
|
|
125
|
+
* policy and fold each update into a RunResult via applyUpdate, capturing the tool
|
|
126
|
+
* trace. Backends in sandbox.ts call this with a wrapped descriptor (isolated => "allow")
|
|
127
|
+
* or the raw descriptor (child-spawn => env policy).
|
|
128
|
+
*
|
|
129
|
+
* SECURITY: On the isolated path (macos-seatbelt / linux-bubblewrap), auto-allow is
|
|
130
|
+
* safe by default — the OS-native FS boundary bounds the blast radius to the run dir
|
|
131
|
+
* and temp. On the child-spawn fallback, permission is "deny" unless
|
|
132
|
+
* AGENTGEM_GEM_RUN_AUTOALLOW=1 is set (env escape hatch, retained for trusted local
|
|
133
|
+
* sessions). Combined with the loopback origin guard and the server-derived run dir,
|
|
134
|
+
* this keeps a malicious browser tab from driving a fully-permissioned local agent.
|
|
135
|
+
*/
|
|
136
|
+
export async function connectRunSession(descriptor, permission, _app) {
|
|
137
|
+
const raw = await connectAcpAdapter(descriptor, { clientName: "agentgem-gem-runner", permission });
|
|
138
|
+
const ctx = {
|
|
139
|
+
async open(cwd) {
|
|
140
|
+
const session = await raw.open(cwd);
|
|
141
|
+
return {
|
|
142
|
+
setMode: (mode) => session.setMode(mode),
|
|
143
|
+
async prompt(text, onDelta, onToolCall) {
|
|
144
|
+
const acc = createAccumulator();
|
|
145
|
+
await session.prompt(text, (u) => applyUpdate(acc, (u ?? {}), { onDelta, onToolCall }));
|
|
146
|
+
return acc;
|
|
147
|
+
},
|
|
148
|
+
dispose: () => session.dispose(),
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
return { ctx, close: raw.close };
|
|
153
|
+
}
|
|
154
|
+
// Back-compat: the unsandboxed child-spawn connect, env-gated via the single source of
|
|
155
|
+
// truth for the auto-allow flag (shared with sandbox.ts's child-spawn backend).
|
|
156
|
+
export const defaultRunConnectFn = (descriptor, app) => connectRunSession(descriptor, envPermission(), app);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// src/gem/acpSession.ts
|
|
2
|
+
//
|
|
3
|
+
// The shared ACP adapter plumbing used by BOTH the workflow recommender and the
|
|
4
|
+
// Gem runner: spawn the adapter binary, bridge stdio via the SDK, build a session,
|
|
5
|
+
// set its mode, and pump session updates until the turn stops. The two callers
|
|
6
|
+
// differ only in permission policy (deny vs allow) and how they fold updates
|
|
7
|
+
// (text-only string vs structured RunResult), so those stay in the callers — this
|
|
8
|
+
// module owns the boilerplate that was previously copy-pasted between them.
|
|
9
|
+
//
|
|
10
|
+
// NEEDS LIVE VALIDATION: stdio bridging against the real ACP adapter (covered by
|
|
11
|
+
// the runner + recommender live smokes, since both now route through here).
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { mkdirSync } from "node:fs";
|
|
14
|
+
import { Readable, Writable } from "node:stream";
|
|
15
|
+
export async function connectAcpAdapter(descriptor, opts) {
|
|
16
|
+
const { client, ndJsonStream, PROTOCOL_VERSION } = await import("@agentclientprotocol/sdk");
|
|
17
|
+
const [bin, ...args] = descriptor.command;
|
|
18
|
+
const child = spawn(bin, args, { stdio: ["pipe", "pipe", "inherit"], env: process.env });
|
|
19
|
+
await new Promise((resolve, reject) => {
|
|
20
|
+
child.once("spawn", () => resolve());
|
|
21
|
+
child.once("error", (e) => reject(new Error(`failed to spawn ${bin}: ${e.message}`)));
|
|
22
|
+
});
|
|
23
|
+
const app = client({ name: opts.clientName });
|
|
24
|
+
const reply = opts.permission === "allow"
|
|
25
|
+
? { outcome: { outcome: "selected", optionId: "allow" } }
|
|
26
|
+
: { outcome: { outcome: "cancelled" } };
|
|
27
|
+
app.onRequest?.("session/request_permission", async () => reply);
|
|
28
|
+
const input = Readable.toWeb(child.stdout);
|
|
29
|
+
const output = Writable.toWeb(child.stdin);
|
|
30
|
+
const connection = app.connect(ndJsonStream(output, input));
|
|
31
|
+
const agentCtx = connection.agent;
|
|
32
|
+
// ACP requires an `initialize` handshake before any session/new. claude-agent-acp
|
|
33
|
+
// tolerated skipping it; codex-acp strictly rejects session/new with "Not
|
|
34
|
+
// initialized" (-32603) without it. We advertise no client capabilities we don't
|
|
35
|
+
// implement (no fs/terminal handlers) — both adapters write files directly.
|
|
36
|
+
await agentCtx.request("initialize", { protocolVersion: PROTOCOL_VERSION });
|
|
37
|
+
return {
|
|
38
|
+
async open(cwd) {
|
|
39
|
+
try {
|
|
40
|
+
mkdirSync(cwd, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
catch { /* best-effort */ }
|
|
43
|
+
const session = await agentCtx.buildSession(cwd).start();
|
|
44
|
+
const sessionId = session.sessionId;
|
|
45
|
+
return {
|
|
46
|
+
async setMode(mode) {
|
|
47
|
+
try {
|
|
48
|
+
await agentCtx.request("session/set_mode", { sessionId, modeId: mode });
|
|
49
|
+
}
|
|
50
|
+
catch { /* best-effort */ }
|
|
51
|
+
},
|
|
52
|
+
async prompt(text, onUpdate) {
|
|
53
|
+
void session.prompt(text);
|
|
54
|
+
for (;;) {
|
|
55
|
+
const msg = await session.nextUpdate();
|
|
56
|
+
if (msg.kind === "stop")
|
|
57
|
+
break;
|
|
58
|
+
if (msg.kind === "session_update")
|
|
59
|
+
onUpdate(msg.update);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
dispose() { try {
|
|
63
|
+
session.dispose?.();
|
|
64
|
+
}
|
|
65
|
+
catch { /* ignore */ } },
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
close: () => {
|
|
69
|
+
try {
|
|
70
|
+
connection.close();
|
|
71
|
+
}
|
|
72
|
+
catch { /* ignore */ }
|
|
73
|
+
try {
|
|
74
|
+
child.kill();
|
|
75
|
+
}
|
|
76
|
+
catch { /* ignore */ }
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -10,7 +10,11 @@ import { join, dirname } from "node:path";
|
|
|
10
10
|
import { agentgemHome } from "../resolveDir.js";
|
|
11
11
|
const MAX_ENTRIES = 50;
|
|
12
12
|
function cachePath() { return join(agentgemHome(), ".agentgem", "analysis-cache.json"); }
|
|
13
|
-
|
|
13
|
+
// Bump on any change to what an analysis result contains (the token is otherwise
|
|
14
|
+
// content-blind). v2 = the payload now carries the `distilled` track, so v1 entries
|
|
15
|
+
// (which lack it) must not be served (proposal §8).
|
|
16
|
+
const TOKEN_VERSION = "v2";
|
|
17
|
+
/** A cheap validity token: version + transcript count + newest mtime. New/updated session → new token. */
|
|
14
18
|
export function transcriptToken(paths) {
|
|
15
19
|
let maxMs = 0;
|
|
16
20
|
for (const p of paths) {
|
|
@@ -21,7 +25,7 @@ export function transcriptToken(paths) {
|
|
|
21
25
|
}
|
|
22
26
|
catch { /* gone — ignore */ }
|
|
23
27
|
}
|
|
24
|
-
return `${paths.length}:${Math.round(maxMs)}`;
|
|
28
|
+
return `${TOKEN_VERSION}:${paths.length}:${Math.round(maxMs)}`;
|
|
25
29
|
}
|
|
26
30
|
function readAll() {
|
|
27
31
|
try {
|
package/dist/gem/archive.js
CHANGED
|
@@ -86,6 +86,14 @@ export function writeGemArchive(gem, opts = {}) {
|
|
|
86
86
|
if (place(path, JSON.stringify(body, null, 2), a.name, "mcp_server"))
|
|
87
87
|
artifacts.push({ type: "mcp_server", name: a.name, path });
|
|
88
88
|
}
|
|
89
|
+
else if (a.type === "channel") {
|
|
90
|
+
const path = `channels/${withExt(seg, ".json")}`;
|
|
91
|
+
const body = { platform: a.platform, secretRefs: a.secretRefs };
|
|
92
|
+
if (a.description !== undefined)
|
|
93
|
+
body.description = a.description;
|
|
94
|
+
if (place(path, JSON.stringify(body, null, 2), a.name, "channel"))
|
|
95
|
+
artifacts.push({ type: "channel", name: a.name, path });
|
|
96
|
+
}
|
|
89
97
|
else {
|
|
90
98
|
const path = `hooks/${withExt(seg, ".json")}`;
|
|
91
99
|
const body = { event: a.event, config: a.config };
|
|
@@ -159,6 +167,15 @@ export function readGemArchive(files) {
|
|
|
159
167
|
a.secretRefs = o.secretRefs;
|
|
160
168
|
return a;
|
|
161
169
|
}
|
|
170
|
+
if (e.type === "channel") {
|
|
171
|
+
const o = JSON.parse(body(e.path));
|
|
172
|
+
const a = { type: "channel", name: e.name, platform: o.platform, secretRefs: o.secretRefs };
|
|
173
|
+
if (o.description !== undefined)
|
|
174
|
+
a.description = o.description;
|
|
175
|
+
return a;
|
|
176
|
+
}
|
|
177
|
+
if (e.type !== "hook")
|
|
178
|
+
throw new Error(`unknown artifact type '${e.type}' in manifest`);
|
|
162
179
|
const o = JSON.parse(body(e.path));
|
|
163
180
|
const a = { type: "hook", name: e.name, event: o.event, config: o.config };
|
|
164
181
|
if (o.matcher !== undefined)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// src/gem/binPath.ts
|
|
2
|
+
// Leaf helper: is `binName` resolvable on the current PATH? Shared by the adapter
|
|
3
|
+
// resolver (runGem) and the sandbox backend availability checks (sandbox), so neither
|
|
4
|
+
// hard-codes an absolute install path for a tool that distros place in different dirs.
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { delimiter, join } from "node:path";
|
|
7
|
+
export function binOnPath(binName) {
|
|
8
|
+
return (process.env.PATH ?? "").split(delimiter).some((d) => d && existsSync(join(d, binName)));
|
|
9
|
+
}
|
package/dist/gem/buildGem.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { redactMcpConfig } from "./redact.js";
|
|
2
|
+
import { makeChannelArtifact } from "./channels.js";
|
|
2
3
|
export function buildGem(inventory, selection, opts = {}) {
|
|
3
4
|
const artifacts = [];
|
|
4
5
|
const projects = inventory.projects ?? [];
|
|
@@ -68,9 +69,11 @@ export function buildGem(inventory, selection, opts = {}) {
|
|
|
68
69
|
});
|
|
69
70
|
artifacts.length = 0;
|
|
70
71
|
artifacts.push(...guarded);
|
|
72
|
+
for (const ch of opts.channels ?? [])
|
|
73
|
+
artifacts.push(makeChannelArtifact(ch.platform, ch.name));
|
|
71
74
|
const requiredSecrets = [];
|
|
72
75
|
for (const a of artifacts) {
|
|
73
|
-
if ((a.type === "mcp_server" || a.type === "hook") && a.secretRefs) {
|
|
76
|
+
if ((a.type === "mcp_server" || a.type === "hook" || a.type === "channel") && a.secretRefs) {
|
|
74
77
|
for (const ref of a.secretRefs)
|
|
75
78
|
requiredSecrets.push({ name: ref.name, artifact: a.name, location: ref.location });
|
|
76
79
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const CHANNEL_REGISTRY = {
|
|
2
|
+
slack: { platform: "slack", label: "Slack", eveImport: "eve/channels/slack", factory: "slackChannel", secrets: ["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"] },
|
|
3
|
+
telegram: { platform: "telegram", label: "Telegram", eveImport: "eve/channels/telegram", factory: "telegramChannel", secrets: ["TELEGRAM_BOT_TOKEN", "TELEGRAM_WEBHOOK_SECRET_TOKEN"] },
|
|
4
|
+
discord: { platform: "discord", label: "Discord", eveImport: "eve/channels/discord", factory: "discordChannel", secrets: ["DISCORD_BOT_TOKEN", "DISCORD_PUBLIC_KEY", "DISCORD_APPLICATION_ID"] },
|
|
5
|
+
teams: { platform: "teams", label: "Microsoft Teams", eveImport: "eve/channels/teams", factory: "teamsChannel", secrets: ["MICROSOFT_APP_ID", "MICROSOFT_APP_PASSWORD", "MICROSOFT_TENANT_ID"] },
|
|
6
|
+
twilio: { platform: "twilio", label: "Twilio", eveImport: "eve/channels/twilio", factory: "twilioChannel", secrets: ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"] },
|
|
7
|
+
github: { platform: "github", label: "GitHub", eveImport: "eve/channels/github", factory: "githubChannel", secrets: ["GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY", "GITHUB_WEBHOOK_SECRET"] },
|
|
8
|
+
};
|
|
9
|
+
// The agent/channels/<name>.ts file Eve materialization emits. Eve channel factories read their
|
|
10
|
+
// secrets from the environment, so the scaffold is the import + a zero-arg factory default export.
|
|
11
|
+
// NOTE: twilioChannel() requires an `allowFrom` config; its scaffold includes the minimal required arg.
|
|
12
|
+
export function channelScaffold(platform) {
|
|
13
|
+
const spec = CHANNEL_REGISTRY[platform];
|
|
14
|
+
const envComment = `// Reads ${spec.secrets.join(", ")} from the environment (set them as project env vars).\n// See https://vercel.com/docs/eve/concepts#channels\n`;
|
|
15
|
+
if (platform === "twilio") {
|
|
16
|
+
return (`import { ${spec.factory} } from ${JSON.stringify(spec.eveImport)};\n\n` +
|
|
17
|
+
envComment +
|
|
18
|
+
`export default ${spec.factory}({\n allowFrom: "*", // restrict to your Twilio numbers in production\n});\n`);
|
|
19
|
+
}
|
|
20
|
+
return (`import { ${spec.factory} } from ${JSON.stringify(spec.eveImport)};\n\n` +
|
|
21
|
+
envComment +
|
|
22
|
+
`export default ${spec.factory}();\n`);
|
|
23
|
+
}
|
|
24
|
+
// Build a neutral channel artifact, resolving the platform's env-var secrets into secretRefs.
|
|
25
|
+
export function makeChannelArtifact(platform, name) {
|
|
26
|
+
const spec = CHANNEL_REGISTRY[platform];
|
|
27
|
+
const secretRefs = spec.secrets.map((s) => ({ name: s, location: `env.${s}` }));
|
|
28
|
+
return { type: "channel", name: name ?? platform, platform, secretRefs };
|
|
29
|
+
}
|
package/dist/gem/credentials.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
import { agentgemHome } from "../resolveDir.js";
|
|
9
|
+
import { InvalidInputError } from "./inputError.js";
|
|
9
10
|
// Only these keys may be set/persisted via the API — never arbitrary env vars.
|
|
10
11
|
export const CREDENTIAL_KEYS = ["ANTHROPIC_API_KEY", "VERCEL_TOKEN", "CLOUDFLARE_API_TOKEN"];
|
|
11
12
|
export function credentialsEnvPath(home = agentgemHome()) {
|
|
@@ -16,9 +17,9 @@ export function credentialsEnvPath(home = agentgemHome()) {
|
|
|
16
17
|
export function setCredential(key, value, home = agentgemHome()) {
|
|
17
18
|
const v = value.trim();
|
|
18
19
|
if (!v)
|
|
19
|
-
throw new
|
|
20
|
+
throw new InvalidInputError("credential value is empty");
|
|
20
21
|
if (/[\r\n]/.test(v))
|
|
21
|
-
throw new
|
|
22
|
+
throw new InvalidInputError("credential value must be a single line");
|
|
22
23
|
process.env[key] = v;
|
|
23
24
|
const abs = credentialsEnvPath(home);
|
|
24
25
|
const kept = (existsSync(abs) ? readFileSync(abs, "utf8") : "")
|