loopengine 0.3.8 → 0.3.10
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/dist/bin/ability-manager.js +5 -0
- package/dist/core/ability-sources.d.ts +3 -1
- package/dist/core/ability-sources.js +1 -1
- package/dist/core/mcp-directory.d.ts +16 -0
- package/dist/core/mcp-directory.js +96 -3
- package/dist/core/messaging/connectors/telegram/index.d.ts +4 -0
- package/dist/core/messaging/connectors/telegram/index.js +10 -1
- package/dist/core/model-calls/openai-model-call.d.ts +4 -0
- package/dist/core/model-calls/openai-model-call.js +12 -2
- package/dist/core/official-abilities.d.ts +14 -0
- package/dist/core/official-abilities.js +58 -0
- package/dist/core/system-agents/templates.d.ts +9 -0
- package/dist/core/system-agents/templates.js +127 -61
- package/dist/core/system-agents/workflow-planner.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/web/abilities-admin.js +18 -2
- package/dist/web/actauth-admin.d.ts +33 -1
- package/dist/web/actauth-admin.js +39 -2
- package/dist/web/agents-config-page.js +41 -36
- package/dist/web/console-shell.js +6 -5
- package/dist/web/global-config-page.js +82 -6
- package/dist/web/notifier-admin.d.ts +2 -0
- package/dist/web/notifier-admin.js +3 -1
- package/dist/web/playground.js +1 -1
- package/dist/web/templates-admin.js +25 -16
- package/dist/web/templates-page.js +6 -5
- package/dist/web/workflow-admin.js +8 -15
- package/dist/web/workflow-page.js +1 -1
- package/package.json +3 -1
|
@@ -26,6 +26,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
26
26
|
import semver from 'semver';
|
|
27
27
|
import { agentDir } from '../core/gateway-tools.js';
|
|
28
28
|
import { abilityFetchAuth } from '../core/ability-sources.js';
|
|
29
|
+
import { assertOfficialAbility } from '../core/official-abilities.js';
|
|
29
30
|
import { clearMcpSchemaCache, resolveMcpManifest, McpManifestError } from '../core/mcp-abilities.js';
|
|
30
31
|
import { appRuleName } from '../core/mcp-ability-generator.js';
|
|
31
32
|
import { addToolToIndex, removeToolFromIndex, toCamelCase } from '../web/http-tool-admin.js';
|
|
@@ -363,6 +364,8 @@ function resolveMcp(manifest) {
|
|
|
363
364
|
}
|
|
364
365
|
}
|
|
365
366
|
export async function installAbility(agentName, spec, options = {}) {
|
|
367
|
+
// A public ability only if LoopEngine publishes it (core/official-abilities.ts).
|
|
368
|
+
assertOfficialAbility(spec);
|
|
366
369
|
const fetch = options.fetchAbilityDir ?? fetchAbilityDir;
|
|
367
370
|
const abilityDir = fetch(spec);
|
|
368
371
|
const manifest = readManifest(abilityDir);
|
|
@@ -635,6 +638,8 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
|
|
|
635
638
|
throw new AbilityNotInstalledError(`Ability '${abilityName}' isn't installed for '${agentName}'.`);
|
|
636
639
|
}
|
|
637
640
|
const spec = options.spec ?? abilityName;
|
|
641
|
+
// New code is fetched: the same check as an install.
|
|
642
|
+
assertOfficialAbility(spec);
|
|
638
643
|
const oldDir = fetch(oldContentSpec(record.spec, abilityName, record.version));
|
|
639
644
|
const newDir = fetch(spec);
|
|
640
645
|
const oldManifest = readManifest(oldDir);
|
|
@@ -20,7 +20,9 @@ export interface SourceAbility {
|
|
|
20
20
|
spec: string;
|
|
21
21
|
}
|
|
22
22
|
export interface AbilityOrigin {
|
|
23
|
-
|
|
23
|
+
/** official: under LoopEngine's @loopengine-co scope; public: any
|
|
24
|
+
* other npm package (an older lp-* one, say). */
|
|
25
|
+
kind: 'official' | 'public' | 'private';
|
|
24
26
|
/** Where it came from, for the console's badge: "npm", "GitHub
|
|
25
27
|
* loopengine-co/lp-github", "Folder ../loopengine-abilities", ... */
|
|
26
28
|
label: string;
|
|
@@ -260,7 +260,7 @@ export function abilityOrigin(spec, name, sources = safeReadSources()) {
|
|
|
260
260
|
const npmSource = scope ? sources.find((s) => s.type === 'npm' && s.scope === scope) : undefined;
|
|
261
261
|
if (npmSource)
|
|
262
262
|
return { kind: 'private', label: describeSource(npmSource) };
|
|
263
|
-
return { kind: 'public', label: 'npm' };
|
|
263
|
+
return { kind: target.toLowerCase().startsWith('@loopengine-co/') ? 'official' : 'public', label: 'npm' };
|
|
264
264
|
}
|
|
265
265
|
function safeReadSources() {
|
|
266
266
|
try {
|
|
@@ -26,6 +26,22 @@ export declare const MCP_REGISTRY_URL_ENV = "LOOPENGINE_MCP_REGISTRY_URL";
|
|
|
26
26
|
export declare const MCP_SERVERS_FILE_ENV = "LOOPENGINE_MCP_SERVERS_FILE";
|
|
27
27
|
/** The workspace's saved servers. A malformed entry is skipped. */
|
|
28
28
|
export declare function savedMcpServers(): McpDirectoryServer[];
|
|
29
|
+
export declare class McpServerInputError extends Error {
|
|
30
|
+
}
|
|
31
|
+
/** A server the workspace adds by its address, checked and given an id
|
|
32
|
+
* from its name (the same id again for an address already saved). Not
|
|
33
|
+
* saved yet: saveMcpServer, once it has connected. */
|
|
34
|
+
export declare function ownMcpServer(input: {
|
|
35
|
+
title?: unknown;
|
|
36
|
+
url?: unknown;
|
|
37
|
+
description?: unknown;
|
|
38
|
+
header?: unknown;
|
|
39
|
+
}, saved?: McpDirectoryServer[]): McpDirectoryServer;
|
|
40
|
+
/** Adds a server to the saved servers, or replaces the one with its id.
|
|
41
|
+
* The file's other entries are kept as they are. */
|
|
42
|
+
export declare function saveMcpServer(server: McpDirectoryServer): void;
|
|
43
|
+
/** Removes a saved server; false when there's none with that id. */
|
|
44
|
+
export declare function removeSavedMcpServer(id: string): boolean;
|
|
29
45
|
/** Saved servers matching `query`, then registry ones, verified first. */
|
|
30
46
|
export declare function searchMcpServers(query: string, limit?: number): Promise<McpDirectoryServer[]>;
|
|
31
47
|
/** One server by id: a saved one, else the registry's latest entry. */
|
|
@@ -11,12 +11,15 @@
|
|
|
11
11
|
// url: https://mcp.acme.internal/mcp
|
|
12
12
|
// auth: { header: X-Api-Key } # optional; default Authorization: Bearer
|
|
13
13
|
//
|
|
14
|
+
// The console's Apps page adds to the same file ("Add your own server"):
|
|
15
|
+
// a company's own server, connected like any app.
|
|
16
|
+
//
|
|
14
17
|
// A registry server counts as verified when its URL is on the domain its
|
|
15
18
|
// registry name was verified for (com.stripe/mcp at mcp.stripe.com); the
|
|
16
19
|
// rest are shown as unverified.
|
|
17
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
21
|
import { resolve } from 'node:path';
|
|
19
|
-
import { parse as parseYaml } from 'yaml';
|
|
22
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
20
23
|
export const MCP_REGISTRY_URL_ENV = 'LOOPENGINE_MCP_REGISTRY_URL';
|
|
21
24
|
export const MCP_SERVERS_FILE_ENV = 'LOOPENGINE_MCP_SERVERS_FILE';
|
|
22
25
|
const DEFAULT_REGISTRY = 'https://registry.modelcontextprotocol.io';
|
|
@@ -27,9 +30,12 @@ function registryBase() {
|
|
|
27
30
|
return undefined;
|
|
28
31
|
return (value || DEFAULT_REGISTRY).replace(/\/+$/, '');
|
|
29
32
|
}
|
|
33
|
+
function savedServersPath() {
|
|
34
|
+
return resolve(process.cwd(), process.env[MCP_SERVERS_FILE_ENV]?.trim() || 'mcp-servers.yml');
|
|
35
|
+
}
|
|
30
36
|
/** The workspace's saved servers. A malformed entry is skipped. */
|
|
31
37
|
export function savedMcpServers() {
|
|
32
|
-
const path =
|
|
38
|
+
const path = savedServersPath();
|
|
33
39
|
if (!existsSync(path))
|
|
34
40
|
return [];
|
|
35
41
|
let raw;
|
|
@@ -71,6 +77,93 @@ function isRemoteUrl(url) {
|
|
|
71
77
|
return false;
|
|
72
78
|
}
|
|
73
79
|
}
|
|
80
|
+
export class McpServerInputError extends Error {
|
|
81
|
+
}
|
|
82
|
+
const HEADER_NAME = /^[A-Za-z0-9-]{1,64}$/;
|
|
83
|
+
/** A server the workspace adds by its address, checked and given an id
|
|
84
|
+
* from its name (the same id again for an address already saved). Not
|
|
85
|
+
* saved yet: saveMcpServer, once it has connected. */
|
|
86
|
+
export function ownMcpServer(input, saved = savedMcpServers()) {
|
|
87
|
+
const text = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
88
|
+
const title = text(input.title);
|
|
89
|
+
const url = text(input.url);
|
|
90
|
+
const description = text(input.description);
|
|
91
|
+
const header = text(input.header);
|
|
92
|
+
if (!title)
|
|
93
|
+
throw new McpServerInputError('Give the server a name, e.g. "Acme CRM".');
|
|
94
|
+
if (title.length > 60)
|
|
95
|
+
throw new McpServerInputError('Keep the name under 60 characters.');
|
|
96
|
+
if (description.length > 200)
|
|
97
|
+
throw new McpServerInputError('Keep the description under 200 characters.');
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = new URL(url);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
throw new McpServerInputError('Enter the server\'s full address, e.g. https://mcp.yourcompany.com/mcp.');
|
|
104
|
+
}
|
|
105
|
+
if (!isRemoteUrl(parsed.href)) {
|
|
106
|
+
throw new McpServerInputError(parsed.protocol === 'http:'
|
|
107
|
+
? 'Use the server\'s https address: its key would otherwise be sent unencrypted.'
|
|
108
|
+
: 'Enter the server\'s full https address, with nothing left to fill in, e.g. https://mcp.yourcompany.com/mcp.');
|
|
109
|
+
}
|
|
110
|
+
if (header && !HEADER_NAME.test(header))
|
|
111
|
+
throw new McpServerInputError('A header name is letters, digits and dashes, e.g. X-Api-Key.');
|
|
112
|
+
const same = saved.find((s) => s.url === url);
|
|
113
|
+
let id = same?.id;
|
|
114
|
+
if (!id) {
|
|
115
|
+
const base = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'server';
|
|
116
|
+
id = base;
|
|
117
|
+
for (let n = 2; saved.some((s) => s.id === id); n++)
|
|
118
|
+
id = `${base}-${n}`;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
id,
|
|
122
|
+
title,
|
|
123
|
+
description,
|
|
124
|
+
url,
|
|
125
|
+
transport: 'streamable-http',
|
|
126
|
+
source: 'saved',
|
|
127
|
+
verified: true,
|
|
128
|
+
...(header && header.toLowerCase() !== 'authorization' ? { auth: { header } } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Adds a server to the saved servers, or replaces the one with its id.
|
|
132
|
+
* The file's other entries are kept as they are. */
|
|
133
|
+
export function saveMcpServer(server) {
|
|
134
|
+
const path = savedServersPath();
|
|
135
|
+
let doc = {};
|
|
136
|
+
if (existsSync(path)) {
|
|
137
|
+
try {
|
|
138
|
+
doc = parseYaml(readFileSync(path, 'utf8')) ?? {};
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
throw new McpServerInputError(`${path} couldn't be read, so the server wasn't saved: ${err instanceof Error ? err.message : String(err)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const servers = Array.isArray(doc.servers) ? doc.servers.filter((e) => e?.id !== server.id) : [];
|
|
145
|
+
servers.push({
|
|
146
|
+
id: server.id,
|
|
147
|
+
title: server.title,
|
|
148
|
+
...(server.description ? { description: server.description } : {}),
|
|
149
|
+
url: server.url,
|
|
150
|
+
...(server.auth?.header ? { auth: { header: server.auth.header } } : {}),
|
|
151
|
+
});
|
|
152
|
+
writeFileSync(path, stringifyYaml({ ...doc, servers }));
|
|
153
|
+
}
|
|
154
|
+
/** Removes a saved server; false when there's none with that id. */
|
|
155
|
+
export function removeSavedMcpServer(id) {
|
|
156
|
+
const path = savedServersPath();
|
|
157
|
+
if (!existsSync(path))
|
|
158
|
+
return false;
|
|
159
|
+
const doc = parseYaml(readFileSync(path, 'utf8')) ?? {};
|
|
160
|
+
const servers = Array.isArray(doc.servers) ? doc.servers : [];
|
|
161
|
+
const kept = servers.filter((e) => e?.id !== id);
|
|
162
|
+
if (kept.length === servers.length)
|
|
163
|
+
return false;
|
|
164
|
+
writeFileSync(path, stringifyYaml({ ...doc, servers: kept }));
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
74
167
|
/** io.github.firecrawl/… → firecrawl: the GitHub organisation the
|
|
75
168
|
* registry verified the name for. */
|
|
76
169
|
function githubOrg(name) {
|
|
@@ -31,9 +31,13 @@ export declare function setTelegramWebhook(botToken: string, url: string, secret
|
|
|
31
31
|
botUsername?: string;
|
|
32
32
|
}>;
|
|
33
33
|
/** Where the bot's updates go now, and the last delivery error Telegram saw. */
|
|
34
|
+
/** Where the bot's updates go now; the last delivery error Telegram saw
|
|
35
|
+
* and when (it keeps it until a newer one); and how many are waiting. */
|
|
34
36
|
export declare function telegramWebhookInfo(botToken: string): Promise<{
|
|
35
37
|
ok: boolean;
|
|
36
38
|
url?: string;
|
|
37
39
|
lastError?: string;
|
|
40
|
+
lastErrorAt?: string;
|
|
41
|
+
pending?: number;
|
|
38
42
|
error?: string;
|
|
39
43
|
}>;
|
|
@@ -121,11 +121,20 @@ export async function setTelegramWebhook(botToken, url, secret) {
|
|
|
121
121
|
return { ok: true, botUsername: await telegramBotUsername(botToken) };
|
|
122
122
|
}
|
|
123
123
|
/** Where the bot's updates go now, and the last delivery error Telegram saw. */
|
|
124
|
+
/** Where the bot's updates go now; the last delivery error Telegram saw
|
|
125
|
+
* and when (it keeps it until a newer one); and how many are waiting. */
|
|
124
126
|
export async function telegramWebhookInfo(botToken) {
|
|
125
127
|
const info = await callTelegram(botToken, 'getWebhookInfo', {});
|
|
126
128
|
if (!info.ok)
|
|
127
129
|
return { ok: false, error: telegramSetupError(info.description) };
|
|
128
|
-
|
|
130
|
+
const r = info.result;
|
|
131
|
+
return {
|
|
132
|
+
ok: true,
|
|
133
|
+
url: r?.url || undefined,
|
|
134
|
+
...(r?.last_error_message ? { lastError: r.last_error_message } : {}),
|
|
135
|
+
...(r?.last_error_date ? { lastErrorAt: new Date(r.last_error_date * 1000).toISOString() } : {}),
|
|
136
|
+
...(r?.pending_update_count ? { pending: r.pending_update_count } : {}),
|
|
137
|
+
};
|
|
129
138
|
}
|
|
130
139
|
function telegramSetupError(description) {
|
|
131
140
|
if (/unauthorized|not found/i.test(description ?? ''))
|
|
@@ -34,5 +34,9 @@ export declare function toResponsesInput(message: Message): ResponseInputItem[];
|
|
|
34
34
|
* blocks, message text, and function calls as tool_use blocks (their
|
|
35
35
|
* call_id is the id a tool result answers). */
|
|
36
36
|
export declare function fromResponse(response: OpenAI.Responses.Response): ModelResponse;
|
|
37
|
+
/** OpenAI's reasoning models (GPT-5 on, the o-series), which reason by
|
|
38
|
+
* default and take a reasoning effort; older ones (gpt-4o, gpt-4.1)
|
|
39
|
+
* reject one. */
|
|
40
|
+
export declare function isOpenAIReasoningModel(model: string): boolean;
|
|
37
41
|
export declare function createOpenAIModelCall(options: OpenAIModelCallOptions): ModelCall;
|
|
38
42
|
export {};
|
|
@@ -232,10 +232,20 @@ export function fromResponse(response) {
|
|
|
232
232
|
const usage = response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined;
|
|
233
233
|
return { stop_reason, content, ...(usage ? { usage } : {}) };
|
|
234
234
|
}
|
|
235
|
+
/** OpenAI's reasoning models (GPT-5 on, the o-series), which reason by
|
|
236
|
+
* default and take a reasoning effort; older ones (gpt-4o, gpt-4.1)
|
|
237
|
+
* reject one. */
|
|
238
|
+
export function isOpenAIReasoningModel(model) {
|
|
239
|
+
return /^(gpt-(5|6|7|8|9)|o\d)/i.test(model);
|
|
240
|
+
}
|
|
235
241
|
export function createOpenAIModelCall(options) {
|
|
236
242
|
const client = options.client ?? new OpenAI({ apiKey: options.apiKey });
|
|
243
|
+
// A reasoning model with no effort set reasons at OpenAI's default
|
|
244
|
+
// anyway (medium), which used to spend the 4096-token cap before it
|
|
245
|
+
// got to a tool call. Unset is low for those: quick, and room to act.
|
|
246
|
+
const reasoningEffort = options.reasoningEffort ?? (isOpenAIReasoningModel(options.model) ? 'low' : undefined);
|
|
237
247
|
// Reasoning counts toward max_output_tokens.
|
|
238
|
-
const maxTokens = options.maxTokens ?? (
|
|
248
|
+
const maxTokens = options.maxTokens ?? (reasoningEffort && reasoningEffort !== 'none' ? 16000 : 4096);
|
|
239
249
|
return async (messages, system, tools) => {
|
|
240
250
|
const response = await client.responses.create({
|
|
241
251
|
model: options.model,
|
|
@@ -245,7 +255,7 @@ export function createOpenAIModelCall(options) {
|
|
|
245
255
|
tools: tools.length
|
|
246
256
|
? tools.map((t) => ({ type: 'function', name: t.name, description: t.description, parameters: t.input_schema, strict: false }))
|
|
247
257
|
: undefined,
|
|
248
|
-
...(
|
|
258
|
+
...(reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}),
|
|
249
259
|
store: false,
|
|
250
260
|
include: ['reasoning.encrypted_content'],
|
|
251
261
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** LoopEngine's npm organisation: its scope is official by itself. */
|
|
2
|
+
export declare const OFFICIAL_ABILITY_SCOPE = "@loopengine-co";
|
|
3
|
+
/** Whether a package name is under LoopEngine's npm organisation. */
|
|
4
|
+
export declare function isOfficialScope(name: string): boolean;
|
|
5
|
+
export declare class UnofficialAbilityError extends Error {
|
|
6
|
+
}
|
|
7
|
+
export declare function unofficialAbilitiesAllowed(): boolean;
|
|
8
|
+
/** The package name when a spec installs from the public npm registry:
|
|
9
|
+
* a bare name or @scope/name, with an optional @version, whose scope isn't
|
|
10
|
+
* one of the workspace's private registries. Undefined for anything else. */
|
|
11
|
+
export declare function publicNpmPackage(spec: string): string | undefined;
|
|
12
|
+
/** Refuses a public npm ability that isn't official, before anything is
|
|
13
|
+
* fetched. */
|
|
14
|
+
export declare function assertOfficialAbility(spec: string): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Official abilities: of the public ones on npm, only those LoopEngine
|
|
2
|
+
// publishes are listed and installed. Anyone can publish a package with the
|
|
3
|
+
// loopengine-ability keyword, or an lp-* name, and an ability runs code in
|
|
4
|
+
// the agent's server with the agent's keys; so "official" is decided by who
|
|
5
|
+
// can publish it: anything under the @loopengine-co scope, which only
|
|
6
|
+
// members of LoopEngine's npm organisation can publish to. The older
|
|
7
|
+
// unscoped ones (lp-gmail and the rest) aren't, and can't be installed or
|
|
8
|
+
// upgraded; their official versions are under the scope.
|
|
9
|
+
//
|
|
10
|
+
// Private abilities are the workspace's own and stay allowed: a workspace
|
|
11
|
+
// ability (file:), one from a GitHub source (github:owner/repo, git URLs),
|
|
12
|
+
// and one from a private npm registry the workspace set up for a scope
|
|
13
|
+
// (core/ability-sources.ts).
|
|
14
|
+
//
|
|
15
|
+
// LOOPENGINE_ALLOW_UNOFFICIAL_ABILITIES=1 turns the check off, for someone
|
|
16
|
+
// developing their own public ability on a self-hosted server.
|
|
17
|
+
import { readAbilitySources } from './ability-sources.js';
|
|
18
|
+
/** LoopEngine's npm organisation: its scope is official by itself. */
|
|
19
|
+
export const OFFICIAL_ABILITY_SCOPE = '@loopengine-co';
|
|
20
|
+
/** Whether a package name is under LoopEngine's npm organisation. */
|
|
21
|
+
export function isOfficialScope(name) {
|
|
22
|
+
return name.toLowerCase().startsWith(`${OFFICIAL_ABILITY_SCOPE}/`);
|
|
23
|
+
}
|
|
24
|
+
export class UnofficialAbilityError extends Error {
|
|
25
|
+
}
|
|
26
|
+
export function unofficialAbilitiesAllowed() {
|
|
27
|
+
return process.env.LOOPENGINE_ALLOW_UNOFFICIAL_ABILITIES === '1';
|
|
28
|
+
}
|
|
29
|
+
/** The package name when a spec installs from the public npm registry:
|
|
30
|
+
* a bare name or @scope/name, with an optional @version, whose scope isn't
|
|
31
|
+
* one of the workspace's private registries. Undefined for anything else. */
|
|
32
|
+
export function publicNpmPackage(spec) {
|
|
33
|
+
const match = /^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)(?:@[^/]+)?$/i.exec(spec.trim());
|
|
34
|
+
if (!match)
|
|
35
|
+
return undefined;
|
|
36
|
+
const name = match[1];
|
|
37
|
+
if (name.startsWith('@')) {
|
|
38
|
+
const scope = name.split('/')[0].toLowerCase();
|
|
39
|
+
let privateScopes = [];
|
|
40
|
+
try {
|
|
41
|
+
privateScopes = readAbilitySources().flatMap((s) => (s.type === 'npm' ? [s.scope.toLowerCase()] : []));
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
44
|
+
if (privateScopes.includes(scope))
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return name;
|
|
48
|
+
}
|
|
49
|
+
/** Refuses a public npm ability that isn't official, before anything is
|
|
50
|
+
* fetched. */
|
|
51
|
+
export function assertOfficialAbility(spec) {
|
|
52
|
+
if (unofficialAbilitiesAllowed())
|
|
53
|
+
return;
|
|
54
|
+
const name = publicNpmPackage(spec);
|
|
55
|
+
if (!name || isOfficialScope(name))
|
|
56
|
+
return;
|
|
57
|
+
throw new UnofficialAbilityError(`${name} isn't an official LoopEngine ability, so it can't be installed: official abilities are under ${OFFICIAL_ABILITY_SCOPE} on npm (search them on the Abilities page). To use your own, add it as a private ability (Settings → Private ability sources, or upload it on the Abilities page).`);
|
|
58
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentCandidate } from './interviewer.js';
|
|
2
|
+
import type { WorkflowParameter } from '../workflows.js';
|
|
2
3
|
export interface TemplateRule {
|
|
3
4
|
name: string;
|
|
4
5
|
tool: string;
|
|
@@ -9,6 +10,14 @@ export interface TemplateSkill {
|
|
|
9
10
|
id: string;
|
|
10
11
|
description: string;
|
|
11
12
|
body: string;
|
|
13
|
+
/** Makes it a workflow, with `body` as its instructions. `webhook`: its
|
|
14
|
+
* checklist says to add a webhook that sends each result (the URL and
|
|
15
|
+
* the sender's signing secret are set up with the sender). */
|
|
16
|
+
workflow?: {
|
|
17
|
+
title: string;
|
|
18
|
+
parameters?: Record<string, WorkflowParameter>;
|
|
19
|
+
webhook?: boolean;
|
|
20
|
+
};
|
|
12
21
|
}
|
|
13
22
|
export interface TemplateSchedule {
|
|
14
23
|
/** How often, in the scheduler's terms: "10m", "1h", "7d". */
|