amalgm 0.1.146 → 0.1.147

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.
@@ -0,0 +1,383 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const { getAgentConfig } = require('../agent-config/store');
6
+ const { normalizeAgentConfig } = require('../agent-config/schema');
7
+ const { resolveAgent } = require('../agents/store');
8
+ const { hydrateConfigSkills } = require('../skills/store');
9
+ const { defaultToolboxService } = require('../toolbox/service');
10
+ const { getApp } = require('../apps/store');
11
+ const { exportAutomationEntry } = require('./automation-entries');
12
+ const { exportAppEntry } = require('./app-entries');
13
+ const { annotateAppBoundPaths } = require('./tool-bindings');
14
+ const {
15
+ BUNDLE_KIND,
16
+ BUNDLE_SCHEMA_VERSION,
17
+ LEGACY_AGENT_BUNDLE_KIND,
18
+ addUnique,
19
+ cloneJson,
20
+ collectSkillRecords,
21
+ isObject,
22
+ nowIso,
23
+ uniqueStrings,
24
+ withBundleGraph,
25
+ } = require('./graph');
26
+
27
+ const SECRET_KEY_PATTERN = /(api[_-]?key|authorization|bearer|cookie|password|passwd|secret|token|credential|private[_-]?key)/i;
28
+
29
+ function bundleId() {
30
+ return `agent-bundle-${crypto.randomUUID()}`;
31
+ }
32
+
33
+ function portableAgent(agent) {
34
+ return {
35
+ sourceAgentId: agent.id,
36
+ name: agent.name || agent.id,
37
+ description: agent.description || '',
38
+ baseHarnessId: agent.baseHarnessId || agent.adapter || 'codex',
39
+ baseModelId: agent.baseModelId || '',
40
+ modelSettings: isObject(agent.modelSettings) ? cloneJson(agent.modelSettings) : null,
41
+ authMethod: agent.authMethod || 'amalgm',
42
+ };
43
+ }
44
+
45
+ function scrubSecrets(value, path, requires) {
46
+ if (Array.isArray(value)) {
47
+ return value.map((entry, index) => scrubSecrets(entry, `${path}[${index}]`, requires));
48
+ }
49
+ if (!isObject(value)) return value;
50
+
51
+ const out = {};
52
+ for (const [key, child] of Object.entries(value)) {
53
+ const childPath = path ? `${path}.${key}` : key;
54
+ if (SECRET_KEY_PATTERN.test(key)) {
55
+ addUnique(requires.secrets, childPath);
56
+ continue;
57
+ }
58
+ out[key] = scrubSecrets(child, childPath, requires);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function portableTool(tool, requires) {
64
+ return scrubSecrets(cloneJson(tool), `tools.${tool.id}`, requires);
65
+ }
66
+
67
+ function portableAction(action, requires) {
68
+ return scrubSecrets(cloneJson(action), `toolActions.${action.id}`, requires);
69
+ }
70
+
71
+ function countHooks(bundleAgents) {
72
+ return bundleAgents.reduce((count, entry) => (
73
+ count + (Array.isArray(entry.config?.hooks) ? entry.config.hooks.length : 0)
74
+ ), 0);
75
+ }
76
+
77
+ function collectToolRecords(toolIds, requires) {
78
+ const catalog = defaultToolboxService.readCatalog();
79
+ const tools = new Map();
80
+ const actions = new Map();
81
+ const selected = uniqueStrings(toolIds);
82
+
83
+ for (const selectedId of selected) {
84
+ const selectedTool = catalog.tools?.[selectedId];
85
+ const selectedAction = catalog.toolActions?.[selectedId];
86
+ const tool = selectedTool || (selectedAction ? catalog.tools?.[selectedAction.toolId] : null);
87
+
88
+ if (!tool) {
89
+ addUnique(requires.missingTools, selectedId);
90
+ continue;
91
+ }
92
+
93
+ if (tool.origin === 'system') {
94
+ addUnique(requires.systemTools, tool.id);
95
+ }
96
+
97
+ if (!tools.has(tool.id)) tools.set(tool.id, portableTool(tool, requires));
98
+
99
+ if (selectedAction) {
100
+ actions.set(selectedAction.id, portableAction(selectedAction, requires));
101
+ }
102
+
103
+ for (const action of Object.values(catalog.toolActions || {})) {
104
+ if (action?.toolId === tool.id) actions.set(action.id, portableAction(action, requires));
105
+ }
106
+ }
107
+
108
+ return {
109
+ tools: [...tools.values()],
110
+ toolActions: [...actions.values()],
111
+ };
112
+ }
113
+
114
+ function collectAgentGraph(rootAgentId) {
115
+ const seen = new Set();
116
+ const ordered = [];
117
+
118
+ function visit(agentId) {
119
+ if (!agentId || seen.has(agentId)) return;
120
+ seen.add(agentId);
121
+ const agent = resolveAgent(agentId);
122
+ if (!agent) throw new Error(`Agent not found: ${agentId}`);
123
+ const config = hydrateConfigSkills(getAgentConfig(agent));
124
+ ordered.push({
125
+ sourceAgentId: agent.id,
126
+ agent: portableAgent(agent),
127
+ config: normalizeAgentConfig(config),
128
+ });
129
+ for (const subagent of config.subagents || []) {
130
+ if (subagent?.agentId) visit(subagent.agentId);
131
+ }
132
+ }
133
+
134
+ visit(rootAgentId);
135
+ return ordered;
136
+ }
137
+
138
+ function bundleTitleAndDescription(agents, automations, apps, sharedTools) {
139
+ const firstAgent = agents[0]?.agent;
140
+ if (firstAgent) {
141
+ return { title: firstAgent.name || 'Shared agent', description: firstAgent.description || '' };
142
+ }
143
+ const firstAutomation = automations[0]?.automation;
144
+ if (firstAutomation) {
145
+ return { title: firstAutomation.name || 'Shared automation', description: firstAutomation.description || '' };
146
+ }
147
+ const firstApp = apps[0]?.app;
148
+ if (firstApp) {
149
+ return { title: firstApp.name || 'Shared app', description: firstApp.description || '' };
150
+ }
151
+ const firstTool = sharedTools?.[0];
152
+ if (firstTool) {
153
+ return { title: firstTool.name || 'Shared tool', description: firstTool.description || '' };
154
+ }
155
+ return { title: 'Shared bundle', description: '' };
156
+ }
157
+
158
+ function bundleWarnings({ hooks, tools, automations, apps, skippedAppFiles }) {
159
+ return [
160
+ 'Instructions, skill text, tool descriptions, and hook commands are shared as plain text.',
161
+ 'Secrets pasted into free-text instructions or files are not automatically detected.',
162
+ ...(hooks > 0 ? ['This bundle includes executable hook definitions. Review commands before importing.'] : []),
163
+ ...(tools > 0 ? ['Tools can call external services or local commands. Recipients should reconnect credentials on their own machine.'] : []),
164
+ ...(automations > 0 ? ['This bundle includes automations. Their workflows run code and call tools on the importing machine — review workflow scripts before importing.'] : []),
165
+ ...(apps > 0 ? ['This bundle includes apps. Their build and start commands execute on install — review commands before importing.'] : []),
166
+ ...(skippedAppFiles > 0 ? [`${skippedAppFiles} app file${skippedAppFiles === 1 ? ' was' : 's were'} excluded from the bundle (dependencies, build outputs, env files, or size limits). Builds re-run on install.`] : []),
167
+ ];
168
+ }
169
+
170
+ /**
171
+ * Build a portable bundle from any mix of top-level primitives. Each requested
172
+ * id becomes a head; agents pull in their full subagent graph, and agents and
173
+ * automations pull in the toolbox tools they reference.
174
+ */
175
+ function createBundle(input = {}, options = {}) {
176
+ const agentIds = uniqueStrings(input.agentIds);
177
+ const automationIds = uniqueStrings(input.automationIds);
178
+ const appIds = uniqueStrings(input.appIds);
179
+ const toolIds = uniqueStrings(input.toolIds);
180
+ if (agentIds.length === 0 && automationIds.length === 0 && appIds.length === 0 && toolIds.length === 0) {
181
+ throw new Error('At least one agent, automation, app, or tool is required');
182
+ }
183
+
184
+ const requires = {
185
+ runtimes: [],
186
+ auth: [],
187
+ secrets: [],
188
+ bindings: [],
189
+ systemTools: [],
190
+ systemActions: [],
191
+ missingTools: [],
192
+ };
193
+
194
+ const agents = [];
195
+ const seenAgentIds = new Set();
196
+ for (const agentId of agentIds) {
197
+ for (const entry of collectAgentGraph(agentId)) {
198
+ if (seenAgentIds.has(entry.sourceAgentId)) continue;
199
+ seenAgentIds.add(entry.sourceAgentId);
200
+ agents.push(entry);
201
+ }
202
+ }
203
+
204
+ const loadoutToolIds = [];
205
+ for (const entry of agents) {
206
+ addUnique(requires.runtimes, entry.agent.baseHarnessId);
207
+ addUnique(requires.auth, `${entry.agent.baseHarnessId}:${entry.agent.authMethod || 'amalgm'}`);
208
+ loadoutToolIds.push(...(entry.config.loadout?.toolIds || []));
209
+ }
210
+
211
+ const automations = [];
212
+ for (const automationId of automationIds) {
213
+ const exported = exportAutomationEntry(automationId);
214
+ automations.push(exported.entry);
215
+ loadoutToolIds.push(...exported.toolboxToolIds);
216
+ for (const ref of exported.systemRefs) addUnique(requires.systemActions, ref);
217
+ for (const ref of exported.missingRefs) addUnique(requires.missingTools, ref);
218
+ }
219
+
220
+ // Tool → app closure: an app-owned tool only works next to its app, so any
221
+ // requested tool whose record points at an app pulls that app in as a
222
+ // dependency (not a head). Stale pointers degrade to a warning.
223
+ const appLinkWarnings = [];
224
+ const dependencyAppIds = [];
225
+ const catalog = defaultToolboxService.readCatalog();
226
+ for (const selectedId of uniqueStrings([...loadoutToolIds, ...toolIds])) {
227
+ const selectedAction = catalog.toolActions?.[selectedId];
228
+ const tool = catalog.tools?.[selectedId]
229
+ || (selectedAction ? catalog.tools?.[selectedAction.toolId] : null);
230
+ const owningAppId = typeof tool?.appId === 'string' ? tool.appId.trim() : '';
231
+ if (!owningAppId || appIds.includes(owningAppId) || dependencyAppIds.includes(owningAppId)) continue;
232
+ if (!getApp(owningAppId)) {
233
+ appLinkWarnings.push(`Tool ${tool.id} belongs to app ${owningAppId}, which no longer exists; the app was not bundled.`);
234
+ continue;
235
+ }
236
+ dependencyAppIds.push(owningAppId);
237
+ }
238
+
239
+ const apps = [];
240
+ const appCwdBySourceId = new Map();
241
+ let skippedAppFiles = 0;
242
+ const appToolIds = [];
243
+ for (const appId of [...appIds, ...dependencyAppIds]) {
244
+ const exported = exportAppEntry(appId);
245
+ apps.push(exported.entry);
246
+ appCwdBySourceId.set(exported.entry.sourceAppId, exported.sourceCwd);
247
+ skippedAppFiles += exported.skipped.length;
248
+ // App → tool closure: tools that belong to a bundled app travel with it.
249
+ appToolIds.push(...(exported.entry.toolIds || []));
250
+ }
251
+
252
+ const { tools, toolActions } = collectToolRecords([...loadoutToolIds, ...appToolIds, ...toolIds], requires);
253
+
254
+ // Tool files that live inside a shared app travel with that app: record
255
+ // app-relative bindings so install rewrites them to the recipient's copy.
256
+ // Absolute paths no shared app covers stay machine-bound requirements.
257
+ for (const record of [...tools, ...toolActions]) {
258
+ if (record.origin === 'system') continue;
259
+ const kind = record.toolId ? 'toolAction' : 'tool';
260
+ const { unbound } = annotateAppBoundPaths(record, appCwdBySourceId);
261
+ for (const label of unbound) addUnique(requires.bindings, `${kind}:${record.id}:${label}`);
262
+ }
263
+ // Standalone tool heads must resolve to a real catalog tool — a dependency
264
+ // tool that is missing degrades to a placeholder, but a head cannot.
265
+ const sharedToolIds = toolIds.filter((toolId) => tools.some((tool) => tool.id === toolId));
266
+ const missingSharedToolIds = toolIds.filter((toolId) => !sharedToolIds.includes(toolId));
267
+ if (missingSharedToolIds.length > 0) {
268
+ throw new Error(`Tool not found: ${missingSharedToolIds.join(', ')}`);
269
+ }
270
+ const sharedTools = sharedToolIds.map((toolId) => tools.find((tool) => tool.id === toolId));
271
+ const skills = collectSkillRecords(agents);
272
+ const hooks = countHooks(agents);
273
+ const warnings = [
274
+ ...bundleWarnings({
275
+ hooks,
276
+ tools: tools.length,
277
+ automations: automations.length,
278
+ apps: apps.length,
279
+ skippedAppFiles,
280
+ }),
281
+ ...appLinkWarnings,
282
+ ];
283
+ // Title comes from what the user deliberately shared: dependency apps
284
+ // pulled in by an app-owned tool must not name the bundle.
285
+ const headApps = apps.filter((entry) => appIds.includes(entry.sourceAppId));
286
+ const { title, description } = bundleTitleAndDescription(agents, automations, headApps, sharedTools);
287
+ const heads = [
288
+ ...agentIds.map((id) => ({ type: 'agent', id })),
289
+ ...automationIds.map((id) => ({ type: 'automation', id })),
290
+ ...appIds.map((id) => ({ type: 'app', id })),
291
+ ...sharedToolIds.map((id) => ({ type: 'tool', id })),
292
+ ];
293
+
294
+ const bundle = withBundleGraph({
295
+ kind: automations.length > 0 || apps.length > 0 || sharedToolIds.length > 0
296
+ ? BUNDLE_KIND
297
+ : LEGACY_AGENT_BUNDLE_KIND,
298
+ schemaVersion: BUNDLE_SCHEMA_VERSION,
299
+ bundleId: options.bundleId || bundleId(),
300
+ createdAt: nowIso(),
301
+ rootAgentId: agents[0]?.sourceAgentId || null,
302
+ heads,
303
+ title,
304
+ description,
305
+ agents,
306
+ automations,
307
+ apps,
308
+ tools,
309
+ toolActions,
310
+ skills,
311
+ assets: [],
312
+ requires,
313
+ warnings,
314
+ summary: {
315
+ agents: agents.length,
316
+ automations: automations.length,
317
+ apps: apps.length,
318
+ tools: tools.length,
319
+ toolActions: toolActions.length,
320
+ skills: skills.length,
321
+ hooks,
322
+ assets: 0,
323
+ },
324
+ });
325
+
326
+ const bytes = Buffer.byteLength(JSON.stringify(bundle));
327
+ return {
328
+ bundle,
329
+ preview: {
330
+ bundleId: bundle.bundleId,
331
+ title: bundle.title,
332
+ description: bundle.description,
333
+ summary: bundle.summary,
334
+ requires,
335
+ warnings,
336
+ agents: agents.map((entry) => ({
337
+ sourceAgentId: entry.sourceAgentId,
338
+ name: entry.agent.name,
339
+ description: entry.agent.description,
340
+ baseHarnessId: entry.agent.baseHarnessId,
341
+ baseModelId: entry.agent.baseModelId,
342
+ tools: entry.config.loadout?.toolIds?.length || 0,
343
+ skills: entry.config.skills?.length || 0,
344
+ subagents: entry.config.subagents?.length || 0,
345
+ hooks: entry.config.hooks?.length || 0,
346
+ })),
347
+ automations: automations.map((entry) => ({
348
+ sourceAutomationId: entry.sourceAutomationId,
349
+ name: entry.automation.name,
350
+ description: entry.automation.description,
351
+ triggers: entry.triggers.length,
352
+ workflows: entry.workflows.length,
353
+ tools: entry.toolIds.length,
354
+ })),
355
+ apps: apps.map((entry) => ({
356
+ sourceAppId: entry.sourceAppId,
357
+ name: entry.app.name,
358
+ description: entry.app.description,
359
+ files: entry.files.length,
360
+ totalBytes: entry.totalBytes,
361
+ skippedFiles: entry.skipped.length,
362
+ })),
363
+ tools: tools.map((tool) => ({
364
+ id: tool.id,
365
+ name: tool.name,
366
+ type: tool.type,
367
+ origin: tool.origin,
368
+ actionCount: toolActions.filter((action) => action.toolId === tool.id).length,
369
+ })),
370
+ sizeBytes: bytes,
371
+ },
372
+ };
373
+ }
374
+
375
+ function createAgentBundle(agentId, options = {}) {
376
+ if (!agentId) throw new Error('agent_id is required');
377
+ return createBundle({ agentIds: [agentId] }, options);
378
+ }
379
+
380
+ module.exports = {
381
+ createAgentBundle,
382
+ createBundle,
383
+ };