loopengine 0.3.9 → 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/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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/web/abilities-admin.js +18 -2
- package/dist/web/agents-config-page.js +12 -12
- package/dist/web/console-shell.js +4 -3
- package/dist/web/global-config-page.js +82 -6
- package/dist/web/templates-admin.js +20 -4
- package/dist/web/templates-page.js +6 -5
- 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) {
|
|
@@ -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". */
|
|
@@ -2,9 +2,8 @@ import { nextTimeOfDay, parseTimeOfDay, workspaceTimeZone } from '../time-zone.j
|
|
|
2
2
|
const GMAIL_KEYS = ['GMAIL_CLIENT_ID', 'GMAIL_CLIENT_SECRET', 'GMAIL_REFRESH_TOKEN'];
|
|
3
3
|
const SHOPIFY_KEYS = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_ADMIN_TOKEN'];
|
|
4
4
|
const GITHUB_APP = 'io.github.github/github-mcp-server';
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const DOCS_NOTE = 'Put your policies and help articles in the project\'s docs folder, as Markdown or text files (or point DOCS_SEARCH_PATH at another folder)';
|
|
5
|
+
const FIRECRAWL_APP = 'io.github.firecrawl/firecrawl-mcp-server';
|
|
6
|
+
const policiesNote = (what, skill) => `Write ${what} into its ${skill} skill (Advanced → Skills): the agent answers from it and nothing else`;
|
|
8
7
|
export const STARTER_TEMPLATES = [
|
|
9
8
|
{
|
|
10
9
|
id: 'customer-email-replies',
|
|
@@ -18,9 +17,9 @@ export const STARTER_TEMPLATES = [
|
|
|
18
17
|
risk: 'medium',
|
|
19
18
|
asksFirst: ['sending a reply'],
|
|
20
19
|
needs: [
|
|
21
|
-
{ kind: 'ability', name: '
|
|
22
|
-
{ kind: 'ability', name: '
|
|
23
|
-
{ kind: '
|
|
20
|
+
{ kind: 'ability', name: '@loopengine-co/gmail', description: 'your support inbox' },
|
|
21
|
+
{ kind: 'ability', name: '@loopengine-co/shopify', description: 'your store\'s orders' },
|
|
22
|
+
{ kind: 'other', name: 'Your policies', description: 'shipping, returns and the rest, in its support-policies skill' },
|
|
24
23
|
],
|
|
25
24
|
agent: {
|
|
26
25
|
name: 'customer-email-replies',
|
|
@@ -30,7 +29,7 @@ When asked to handle new emails:
|
|
|
30
29
|
1. Find them with gmail_search, query "is:unread in:inbox". Skip newsletters, receipts, automatic notifications and "no-reply" senders: mark those read and move on.
|
|
31
30
|
2. For each customer email, read the whole conversation with gmail_get_thread first.
|
|
32
31
|
3. Work out what they want: order status, a product question, a return or refund, a complaint, or something else.
|
|
33
|
-
4. Look up what you need before answering: the order with shopify_find_orders (by the order number they give, or their email) and shopify_get_order, and our policies with
|
|
32
|
+
4. Look up what you need before answering: the order with shopify_find_orders (by the order number they give, or their email) and shopify_get_order, and our policies in the support-policies skill (open it with Skill). Never guess an order detail, a date or a policy.
|
|
34
33
|
5. Reply with gmail_send, passing reply_to_message_id so it stays in the same thread: short, friendly, in the customer's language, using their name, answering the question first and saying what happens next.
|
|
35
34
|
6. Mark the email read with gmail_modify_labels (remove "UNREAD").
|
|
36
35
|
|
|
@@ -44,9 +43,31 @@ Rules:
|
|
|
44
43
|
},
|
|
45
44
|
},
|
|
46
45
|
setup: {
|
|
47
|
-
abilities: ['
|
|
46
|
+
abilities: ['@loopengine-co/gmail', '@loopengine-co/shopify'],
|
|
48
47
|
keys: [...GMAIL_KEYS, ...SHOPIFY_KEYS],
|
|
49
|
-
|
|
48
|
+
skills: [{
|
|
49
|
+
id: 'support-policies',
|
|
50
|
+
description: 'Our shipping, returns, refunds and other customer policies. Read it before telling a customer anything about a policy.',
|
|
51
|
+
body: `# Our customer policies
|
|
52
|
+
|
|
53
|
+
Edit this: replace each [bracket] with your own policy. The agent answers customers from this and nothing else.
|
|
54
|
+
|
|
55
|
+
## Shipping
|
|
56
|
+
- Where we ship: [countries]
|
|
57
|
+
- How long it takes: [e.g. 3–5 business days]
|
|
58
|
+
- Tracking: [when and how customers get it]
|
|
59
|
+
|
|
60
|
+
## Returns and refunds
|
|
61
|
+
- Return window: [e.g. 30 days from delivery]
|
|
62
|
+
- What can't be returned: [e.g. sale items, opened cosmetics]
|
|
63
|
+
- How refunds are paid: [e.g. to the original payment method, in 5–10 business days]
|
|
64
|
+
|
|
65
|
+
## Other questions
|
|
66
|
+
- [Order changes and cancellations]
|
|
67
|
+
- [Warranty]
|
|
68
|
+
- [Anything customers often ask]
|
|
69
|
+
`,
|
|
70
|
+
}],
|
|
50
71
|
rules: [
|
|
51
72
|
{ name: 'template-read-mail-allowed', tool: 'gmail_search', decision: 'allow' },
|
|
52
73
|
{ name: 'template-read-thread-allowed', tool: 'gmail_get_thread', decision: 'allow' },
|
|
@@ -57,7 +78,7 @@ Rules:
|
|
|
57
78
|
],
|
|
58
79
|
schedule: { every: '10m', name: 'Check the support inbox', message: 'Handle new customer emails in the support inbox.' },
|
|
59
80
|
needsApprovals: true,
|
|
60
|
-
notes: [
|
|
81
|
+
notes: [policiesNote('your policies', 'support-policies')],
|
|
61
82
|
},
|
|
62
83
|
},
|
|
63
84
|
{
|
|
@@ -72,9 +93,9 @@ Rules:
|
|
|
72
93
|
risk: 'high',
|
|
73
94
|
asksFirst: ['issuing a refund', 'sending the reply'],
|
|
74
95
|
needs: [
|
|
75
|
-
{ kind: 'ability', name: '
|
|
76
|
-
{ kind: 'ability', name: '
|
|
77
|
-
{ kind: '
|
|
96
|
+
{ kind: 'ability', name: '@loopengine-co/gmail', description: 'your support inbox' },
|
|
97
|
+
{ kind: 'ability', name: '@loopengine-co/shopify', description: 'your store\'s orders and refunds' },
|
|
98
|
+
{ kind: 'other', name: 'Your return policy', description: 'in its return-policy skill' },
|
|
78
99
|
],
|
|
79
100
|
agent: {
|
|
80
101
|
name: 'refund-approvals',
|
|
@@ -83,7 +104,7 @@ Rules:
|
|
|
83
104
|
When asked to handle new refund requests:
|
|
84
105
|
1. Find them with gmail_search, query "is:unread in:inbox (refund OR return OR damaged OR broken OR "money back")". Read each conversation with gmail_get_thread. If an email isn't really a refund or return request, leave it unread for the support team.
|
|
85
106
|
2. Find the order with shopify_find_orders (the order number they give, or their email) and read it with shopify_get_order: what was bought, when, how much, what's already been refunded, whether it was delivered.
|
|
86
|
-
3. Check the request against our return policy with
|
|
107
|
+
3. Check the request against our return policy in the return-policy skill (open it with Skill): the return window, whether the item is eligible, and whether the reason is covered.
|
|
87
108
|
4. Decide what to propose: a full refund, a partial refund, refunding shipping, or no refund (and which part of the policy says so).
|
|
88
109
|
5. Work out the exact amount with shopify_preview_refund. Never state an amount you haven't previewed.
|
|
89
110
|
6. Issue it with shopify_create_refund, with a note giving the policy rule and the facts from the order. A person approves it first.
|
|
@@ -99,9 +120,24 @@ Rules:
|
|
|
99
120
|
},
|
|
100
121
|
},
|
|
101
122
|
setup: {
|
|
102
|
-
abilities: ['
|
|
123
|
+
abilities: ['@loopengine-co/gmail', '@loopengine-co/shopify'],
|
|
103
124
|
keys: [...GMAIL_KEYS, ...SHOPIFY_KEYS],
|
|
104
|
-
|
|
125
|
+
skills: [{
|
|
126
|
+
id: 'return-policy',
|
|
127
|
+
description: 'Our return and refund policy: the window, what qualifies, and how much is refunded. Read it before proposing any refund.',
|
|
128
|
+
body: `# Our return policy
|
|
129
|
+
|
|
130
|
+
Edit this: replace each [bracket] with your own policy. The agent proposes refunds from this and nothing else.
|
|
131
|
+
|
|
132
|
+
- Return window: [e.g. 30 days from delivery]
|
|
133
|
+
- Eligible items: [e.g. unused, in the original packaging]
|
|
134
|
+
- Not eligible: [e.g. sale items, gift cards, personalised items]
|
|
135
|
+
- Damaged or wrong items: [e.g. full refund including shipping, no return needed]
|
|
136
|
+
- Change of mind: [e.g. refund of the item price, customer pays return shipping]
|
|
137
|
+
- Shipping costs: [when they're refunded]
|
|
138
|
+
- Partial refunds: [when you offer one, and how much]
|
|
139
|
+
`,
|
|
140
|
+
}],
|
|
105
141
|
rules: [
|
|
106
142
|
{ name: 'template-read-mail-allowed', tool: 'gmail_search', decision: 'allow' },
|
|
107
143
|
{ name: 'template-read-thread-allowed', tool: 'gmail_get_thread', decision: 'allow' },
|
|
@@ -113,7 +149,7 @@ Rules:
|
|
|
113
149
|
],
|
|
114
150
|
schedule: { every: '10m', name: 'Check for refund requests', message: 'Handle new refund and return requests in the support inbox.' },
|
|
115
151
|
needsApprovals: true,
|
|
116
|
-
notes: [
|
|
152
|
+
notes: [policiesNote('your return policy', 'return-policy')],
|
|
117
153
|
},
|
|
118
154
|
},
|
|
119
155
|
{
|
|
@@ -121,15 +157,15 @@ Rules:
|
|
|
121
157
|
category: 'Operations',
|
|
122
158
|
candidate: {
|
|
123
159
|
title: 'Triage incidents',
|
|
124
|
-
summary: 'Takes an alert or a bug report, works out what broke and how bad it is, finds the deploys and changes that likely caused it, and
|
|
125
|
-
trigger: { kind: '
|
|
160
|
+
summary: 'Takes an alert or a bug report, works out what broke and how bad it is, finds the deploys and changes that likely caused it, and sends a summary to Slack. Opening or commenting on issues waits for approval.',
|
|
161
|
+
trigger: { kind: 'event', description: 'when your alerting tool calls its webhook, or someone passes it an alert in Chat or Slack' },
|
|
126
162
|
hoursPerWeek: 3,
|
|
127
163
|
fit: 6,
|
|
128
164
|
risk: 'medium',
|
|
129
165
|
asksFirst: ['opening or commenting on issues'],
|
|
130
166
|
needs: [
|
|
131
167
|
{ kind: 'app', name: 'GitHub', description: 'your repository\'s commits, deploys and issues' },
|
|
132
|
-
{ kind: '
|
|
168
|
+
{ kind: 'messaging', name: 'Slack', description: 'where each summary is sent (its Messaging tab)' },
|
|
133
169
|
],
|
|
134
170
|
agent: {
|
|
135
171
|
name: 'incident-triage',
|
|
@@ -137,32 +173,38 @@ Rules:
|
|
|
137
173
|
|
|
138
174
|
Our repository on GitHub: [owner/name] (edit this). Pass its owner and repo to every GitHub tool.
|
|
139
175
|
|
|
140
|
-
When you get an alert, error or bug report
|
|
141
|
-
1. Summarise it in two sentences: what is failing and who is affected.
|
|
142
|
-
2. Rate the severity: SEV1 (customers can't use the product), SEV2 (a major feature is broken), SEV3 (degraded or a workaround exists), SEV4 (minor).
|
|
143
|
-
3. Find what changed shortly before it started: github_actions_list (method list_workflow_runs) for recent deploys and failed builds, github_list_pull_requests (state closed) for recently merged changes, and github_list_commits (narrowed with since and path) for detail. Match times carefully.
|
|
144
|
-
4. Check whether it's already known with github_search_issues; read a match with github_issue_read (method get).
|
|
145
|
-
5. Post a short summary to Slack with post_to_slack: what's broken, severity, the most likely cause with links, and one next step (who should look, what to check, or whether to roll back).
|
|
146
|
-
6. If an issue already tracks it, add your findings with github_add_issue_comment; otherwise, for SEV1 or SEV2, open one with github_issue_write (method create). Both ask a person first.
|
|
176
|
+
When you get an alert, error or bug report, follow your triage-incident workflow.
|
|
147
177
|
|
|
148
178
|
Rules:
|
|
149
179
|
- Only read on your own; never roll anything back yourself.
|
|
150
180
|
- Say how sure you are. If the evidence is thin, say what would confirm it instead of guessing.
|
|
151
|
-
- Keep the
|
|
181
|
+
- Keep the summary short enough to read on a phone.`,
|
|
152
182
|
exampleMessage: 'Alert: checkout-api error rate 18% (normally under 1%) since 14:05 UTC. Errors: "timeout connecting to payments".',
|
|
153
183
|
},
|
|
154
184
|
},
|
|
155
185
|
setup: {
|
|
156
|
-
abilities: [
|
|
186
|
+
abilities: [],
|
|
157
187
|
apps: [{ id: GITHUB_APP, title: 'GitHub', tools: ['list_commits', 'list_pull_requests', 'actions_list', 'search_issues', 'issue_read', 'issue_write', 'add_issue_comment'] }],
|
|
158
|
-
keys: [
|
|
188
|
+
keys: [],
|
|
189
|
+
skills: [{
|
|
190
|
+
id: 'triage-incident',
|
|
191
|
+
description: 'Triage an alert, error or bug report: what broke, how bad it is, and the change that likely caused it.',
|
|
192
|
+
workflow: { title: 'Triage an incident', parameters: { alert: { description: 'The alert, error or bug report', required: true } }, webhook: true },
|
|
193
|
+
body: `Triage the alert you were given.
|
|
194
|
+
|
|
195
|
+
1. Summarise it in two sentences: what is failing and who is affected.
|
|
196
|
+
2. Rate the severity: SEV1 (customers can't use the product), SEV2 (a major feature is broken), SEV3 (degraded or a workaround exists), SEV4 (minor).
|
|
197
|
+
3. Find what changed shortly before it started: github_actions_list (method list_workflow_runs) for recent deploys and failed builds, github_list_pull_requests (state closed) for recently merged changes, and github_list_commits (narrowed with since and path) for detail. Match times carefully.
|
|
198
|
+
4. Check whether it's already known with github_search_issues; read a match with github_issue_read (method get).
|
|
199
|
+
5. If an issue already tracks it, add your findings with github_add_issue_comment; otherwise, for SEV1 or SEV2, open one with github_issue_write (method create). Both ask a person first.
|
|
200
|
+
6. End with the summary, which is sent to Slack: what's broken, severity, the most likely cause with links, and one next step (who should look, what to check, or whether to roll back).`,
|
|
201
|
+
}],
|
|
159
202
|
rules: [
|
|
160
|
-
{ name: 'template-slack-summary-allowed', tool: 'post_to_slack', decision: 'allow' },
|
|
161
203
|
{ name: 'template-new-issues-need-approval', tool: 'github_issue_write', decision: 'ask' },
|
|
162
204
|
{ name: 'template-comments-need-approval', tool: 'github_add_issue_comment', decision: 'ask' },
|
|
163
205
|
],
|
|
164
206
|
needsApprovals: true,
|
|
165
|
-
notes: ['Send it alerts: paste one into Chat,
|
|
207
|
+
notes: ['Send it alerts: paste one into Chat, mention it in Slack, or have your alerting tool call its webhook'],
|
|
166
208
|
},
|
|
167
209
|
},
|
|
168
210
|
{
|
|
@@ -177,8 +219,8 @@ Rules:
|
|
|
177
219
|
risk: 'high',
|
|
178
220
|
asksFirst: ['replying to the claimant'],
|
|
179
221
|
needs: [
|
|
180
|
-
{ kind: 'ability', name: '
|
|
181
|
-
{ kind: '
|
|
222
|
+
{ kind: 'ability', name: '@loopengine-co/gmail', description: 'the inbox where claims arrive' },
|
|
223
|
+
{ kind: 'other', name: 'Your expense policy', description: 'in its expense-policy skill' },
|
|
182
224
|
],
|
|
183
225
|
agent: {
|
|
184
226
|
name: 'expense-approvals',
|
|
@@ -187,7 +229,7 @@ Rules:
|
|
|
187
229
|
When asked to check new claims:
|
|
188
230
|
1. Find them with gmail_search, query "is:unread in:inbox". Read each with gmail_get_thread.
|
|
189
231
|
2. Read the amount, currency, date, merchant, category and who submitted it, from the email and its receipt (the attachment names show whether a receipt is there).
|
|
190
|
-
3. Check it against
|
|
232
|
+
3. Check it against our expense policy in the expense-policy skill (open it with Skill): the category is allowed, it's under the limit, the receipt matches the amount and date, and it isn't a duplicate of an earlier claim (search the inbox for the same merchant and amount).
|
|
191
233
|
4. Write a one-line verdict: "Within policy" or "Outside policy: <reason>", then the details you checked.
|
|
192
234
|
5. Email the verdict to the approver at the address in the EXPENSE_APPROVER_EMAIL setting with gmail_send, then mark the claim read with gmail_modify_labels.
|
|
193
235
|
|
|
@@ -200,9 +242,30 @@ Rules:
|
|
|
200
242
|
},
|
|
201
243
|
},
|
|
202
244
|
setup: {
|
|
203
|
-
abilities: ['
|
|
245
|
+
abilities: ['@loopengine-co/gmail'],
|
|
204
246
|
keys: [...GMAIL_KEYS],
|
|
205
|
-
|
|
247
|
+
skills: [{
|
|
248
|
+
id: 'expense-policy',
|
|
249
|
+
description: 'Our expense policy: what can be claimed, the limits, and what a claim needs. Read it before giving any verdict.',
|
|
250
|
+
body: `# Our expense policy
|
|
251
|
+
|
|
252
|
+
Edit this: replace each [bracket] with your own policy. The agent checks claims against this and nothing else.
|
|
253
|
+
|
|
254
|
+
## What can be claimed, and the limits
|
|
255
|
+
- Travel: [e.g. economy flights and trains; taxis when there's no public transport]
|
|
256
|
+
- Hotels: [e.g. up to 150 per night]
|
|
257
|
+
- Meals: [e.g. up to 50 per day when travelling]
|
|
258
|
+
- Equipment and software: [e.g. up to 200 without pre-approval]
|
|
259
|
+
|
|
260
|
+
## What can't be claimed
|
|
261
|
+
- [e.g. alcohol, fines, personal entertainment]
|
|
262
|
+
|
|
263
|
+
## What every claim needs
|
|
264
|
+
- An itemised receipt showing the amount, date and merchant
|
|
265
|
+
- [Pre-approval for anything over a limit]
|
|
266
|
+
- Submitted within [e.g. 60 days]
|
|
267
|
+
`,
|
|
268
|
+
}],
|
|
206
269
|
rules: [
|
|
207
270
|
{ name: 'template-read-mail-allowed', tool: 'gmail_search', decision: 'allow' },
|
|
208
271
|
{ name: 'template-read-thread-allowed', tool: 'gmail_get_thread', decision: 'allow' },
|
|
@@ -212,7 +275,7 @@ Rules:
|
|
|
212
275
|
],
|
|
213
276
|
schedule: { every: '15m', name: 'Check for new claims', message: 'Check the new expense claims in the inbox.' },
|
|
214
277
|
needsApprovals: true,
|
|
215
|
-
notes: [
|
|
278
|
+
notes: [policiesNote('your expense policy', 'expense-policy'), 'Set EXPENSE_APPROVER_EMAIL (Environment tab) to the approver\'s address'],
|
|
216
279
|
},
|
|
217
280
|
},
|
|
218
281
|
{
|
|
@@ -227,9 +290,9 @@ Rules:
|
|
|
227
290
|
risk: 'medium',
|
|
228
291
|
asksFirst: ['sending the first email', 'updating the lead'],
|
|
229
292
|
needs: [
|
|
230
|
-
{ kind: 'ability', name: '
|
|
231
|
-
{ kind: '
|
|
232
|
-
{ kind: 'ability', name: '
|
|
293
|
+
{ kind: 'ability', name: '@loopengine-co/hubspot', description: 'your leads in HubSpot' },
|
|
294
|
+
{ kind: 'app', name: 'Firecrawl', description: 'researching each lead on the web' },
|
|
295
|
+
{ kind: 'ability', name: '@loopengine-co/gmail', description: 'the inbox to send from' },
|
|
233
296
|
],
|
|
234
297
|
agent: {
|
|
235
298
|
name: 'lead-follow-up',
|
|
@@ -243,7 +306,7 @@ What we sell (edit this):
|
|
|
243
306
|
When asked to follow up with new leads:
|
|
244
307
|
1. Find them with hubspot_find_contacts: lead_status "NEW", created_in_last_days 2.
|
|
245
308
|
2. For each, read hubspot_get_contact. Skip anyone with an owner, recent notes, or a last-contacted date: someone is already on it.
|
|
246
|
-
3. Research them with
|
|
309
|
+
3. Research them with firecrawl_search and firecrawl_scrape: what their company does, its size, and anything recent that matters (funding, hiring, a launch). Note your sources.
|
|
247
310
|
4. Work out what they most likely need from us, in one sentence.
|
|
248
311
|
5. Send a short first email (under 120 words) with gmail_send: a specific opening based on your research, one sentence on how we help with that need, and one easy question to reply to. A person approves it first.
|
|
249
312
|
6. Log what you sent and found with hubspot_add_note, and set the lead to "ATTEMPTED_TO_CONTACT" with hubspot_update_contact.
|
|
@@ -257,9 +320,12 @@ Rules:
|
|
|
257
320
|
},
|
|
258
321
|
},
|
|
259
322
|
setup: {
|
|
260
|
-
abilities: ['
|
|
261
|
-
|
|
323
|
+
abilities: ['@loopengine-co/hubspot', '@loopengine-co/gmail'],
|
|
324
|
+
apps: [{ id: FIRECRAWL_APP, title: 'Firecrawl', tools: ['firecrawl_search', 'firecrawl_scrape'] }],
|
|
325
|
+
keys: ['HUBSPOT_ACCESS_TOKEN', ...GMAIL_KEYS],
|
|
262
326
|
rules: [
|
|
327
|
+
{ name: 'template-search-allowed', tool: 'firecrawl_search', decision: 'allow' },
|
|
328
|
+
{ name: 'template-read-pages-allowed', tool: 'firecrawl_scrape', decision: 'allow' },
|
|
263
329
|
{ name: 'template-notes-allowed', tool: 'hubspot_add_note', decision: 'allow' },
|
|
264
330
|
{ name: 'template-lead-updates-need-approval', tool: 'hubspot_update_contact', decision: 'ask' },
|
|
265
331
|
{ name: 'template-no-new-contacts', tool: 'hubspot_create_contact', decision: 'deny' },
|
|
@@ -282,7 +348,7 @@ Rules:
|
|
|
282
348
|
risk: 'low',
|
|
283
349
|
asksFirst: ['replying to the sender'],
|
|
284
350
|
needs: [
|
|
285
|
-
{ kind: 'ability', name: '
|
|
351
|
+
{ kind: 'ability', name: '@loopengine-co/gmail', description: 'the shared inbox' },
|
|
286
352
|
{ kind: 'app', name: 'GitHub', description: 'tasks, as GitHub issues' },
|
|
287
353
|
],
|
|
288
354
|
agent: {
|
|
@@ -312,7 +378,7 @@ Rules:
|
|
|
312
378
|
},
|
|
313
379
|
},
|
|
314
380
|
setup: {
|
|
315
|
-
abilities: ['
|
|
381
|
+
abilities: ['@loopengine-co/gmail'],
|
|
316
382
|
apps: [{ id: GITHUB_APP, title: 'GitHub', tools: ['search_issues', 'issue_write', 'add_issue_comment'] }],
|
|
317
383
|
keys: [...GMAIL_KEYS],
|
|
318
384
|
rules: [
|
|
@@ -333,19 +399,19 @@ Rules:
|
|
|
333
399
|
category: 'Research',
|
|
334
400
|
candidate: {
|
|
335
401
|
title: 'Weekly research digest',
|
|
336
|
-
summary: 'Every Monday morning, searches the web for news on the topics you choose and
|
|
402
|
+
summary: 'Every Monday morning, searches the web for news on the topics you choose and sends a short digest with links to Slack.',
|
|
337
403
|
trigger: { kind: 'schedule', description: 'Mondays at 9:00', every: '7d' },
|
|
338
404
|
hoursPerWeek: 2,
|
|
339
405
|
fit: 6,
|
|
340
406
|
risk: 'low',
|
|
341
407
|
asksFirst: [],
|
|
342
408
|
needs: [
|
|
343
|
-
{ kind: '
|
|
344
|
-
{ kind: '
|
|
409
|
+
{ kind: 'app', name: 'Firecrawl', description: 'searching the web and reading pages' },
|
|
410
|
+
{ kind: 'messaging', name: 'Slack', description: 'where each digest is sent (its Messaging tab)' },
|
|
345
411
|
],
|
|
346
412
|
agent: {
|
|
347
413
|
name: 'weekly-research-digest',
|
|
348
|
-
systemPrompt: `You write a weekly research digest
|
|
414
|
+
systemPrompt: `You write a weekly research digest. Your answer is the digest: it's sent to Slack as it is.
|
|
349
415
|
|
|
350
416
|
Topics to follow (edit this list):
|
|
351
417
|
- [Your industry or market]
|
|
@@ -353,29 +419,29 @@ Topics to follow (edit this list):
|
|
|
353
419
|
- [Technologies or regulations you care about]
|
|
354
420
|
|
|
355
421
|
Each week:
|
|
356
|
-
1. Search the web with
|
|
357
|
-
2. Open the most relevant pages with
|
|
422
|
+
1. Search the web with firecrawl_search for what happened in the last 7 days on each topic.
|
|
423
|
+
2. Open the most relevant pages with firecrawl_scrape and read them; don't rely on search snippets.
|
|
358
424
|
3. Write the digest: for each topic, up to three items, each with a one-sentence summary, why it matters to us, and the link.
|
|
359
425
|
4. End with "Worth a closer look": the single most important item, and why.
|
|
360
|
-
5.
|
|
426
|
+
5. Answer with the digest alone, nothing before or after it.
|
|
361
427
|
|
|
362
428
|
Rules:
|
|
363
429
|
- Only include things published in the last 7 days, and give each item's date.
|
|
364
430
|
- Prefer original sources over aggregators. Never invent a link.
|
|
365
431
|
- If a topic had no real news, say so in one line.`,
|
|
366
|
-
scheduleMessage: 'Write
|
|
367
|
-
exampleMessage: 'Write
|
|
432
|
+
scheduleMessage: 'Write this week\'s research digest.',
|
|
433
|
+
exampleMessage: 'Write this week\'s research digest.',
|
|
368
434
|
},
|
|
369
435
|
},
|
|
370
436
|
setup: {
|
|
371
|
-
abilities: [
|
|
372
|
-
|
|
437
|
+
abilities: [],
|
|
438
|
+
apps: [{ id: FIRECRAWL_APP, title: 'Firecrawl', tools: ['firecrawl_search', 'firecrawl_scrape'] }],
|
|
439
|
+
keys: [],
|
|
373
440
|
rules: [
|
|
374
|
-
{ name: 'template-search-allowed', tool: '
|
|
375
|
-
{ name: 'template-
|
|
376
|
-
{ name: 'template-digest-post-allowed', tool: 'post_to_slack', decision: 'allow' },
|
|
441
|
+
{ name: 'template-search-allowed', tool: 'firecrawl_search', decision: 'allow' },
|
|
442
|
+
{ name: 'template-read-pages-allowed', tool: 'firecrawl_scrape', decision: 'allow' },
|
|
377
443
|
],
|
|
378
|
-
schedule: { every: '7d', at: '09:00', weekday: 1, name: 'Weekly research digest', message: 'Write
|
|
444
|
+
schedule: { every: '7d', at: '09:00', weekday: 1, name: 'Weekly research digest', message: 'Write this week\'s research digest.', notify: true },
|
|
379
445
|
needsApprovals: false,
|
|
380
446
|
notes: ['Edit "Topics to follow" at the top of its instructions (Overview tab)'],
|
|
381
447
|
},
|
package/dist/index.d.ts
CHANGED
|
@@ -108,7 +108,7 @@ export { AbilitySourceError, abilityOrigin, readAbilitySources, saveAbilitySourc
|
|
|
108
108
|
export { describeAbilitySources, listPrivateAbilities, saveAbilitySourceFromConsole, removeAbilitySourceFromConsole } from './web/ability-sources-admin.js';
|
|
109
109
|
export { readAbilityDetails, readToolDefinitions, type AbilityDetails } from './core/ability-details.js';
|
|
110
110
|
export { loadMcpAbilityTools, mcpAbilityLoadError, closeMcpConnections, resolveMcpManifest, listMcpServerTools, isMcpAuthError, type McpServerManifest, type McpAuth, type InstalledMcpRecord, type McpToolInfo } from './core/mcp-abilities.js';
|
|
111
|
-
export { searchMcpServers, getMcpServer, savedMcpServers, type McpDirectoryServer } from './core/mcp-directory.js';
|
|
111
|
+
export { searchMcpServers, getMcpServer, savedMcpServers, ownMcpServer, saveMcpServer, removeSavedMcpServer, McpServerInputError, type McpDirectoryServer } from './core/mcp-directory.js';
|
|
112
112
|
export { generateMcpAbility, mcpAbilityNames, McpAbilityGenerationError, type GeneratedMcpAbility, type McpAbilityRequest } from './core/mcp-ability-generator.js';
|
|
113
113
|
export { abilityDetails } from './web/ability-details-admin.js';
|
|
114
114
|
export { WorkspaceAbilityError, listWorkspaceAbilities, addWorkspaceAbility, removeWorkspaceAbility, workspaceAbilitiesDir, type WorkspaceAbility } from './core/workspace-abilities.js';
|
package/dist/index.js
CHANGED
|
@@ -98,7 +98,7 @@ export { AbilitySourceError, abilityOrigin, readAbilitySources, saveAbilitySourc
|
|
|
98
98
|
export { describeAbilitySources, listPrivateAbilities, saveAbilitySourceFromConsole, removeAbilitySourceFromConsole } from './web/ability-sources-admin.js';
|
|
99
99
|
export { readAbilityDetails, readToolDefinitions } from './core/ability-details.js';
|
|
100
100
|
export { loadMcpAbilityTools, mcpAbilityLoadError, closeMcpConnections, resolveMcpManifest, listMcpServerTools, isMcpAuthError } from './core/mcp-abilities.js';
|
|
101
|
-
export { searchMcpServers, getMcpServer, savedMcpServers } from './core/mcp-directory.js';
|
|
101
|
+
export { searchMcpServers, getMcpServer, savedMcpServers, ownMcpServer, saveMcpServer, removeSavedMcpServer, McpServerInputError } from './core/mcp-directory.js';
|
|
102
102
|
export { generateMcpAbility, mcpAbilityNames, McpAbilityGenerationError } from './core/mcp-ability-generator.js';
|
|
103
103
|
export { abilityDetails } from './web/ability-details-admin.js';
|
|
104
104
|
export { WorkspaceAbilityError, listWorkspaceAbilities, addWorkspaceAbility, removeWorkspaceAbility, workspaceAbilitiesDir } from './core/workspace-abilities.js';
|
|
@@ -1,5 +1,16 @@
|
|
|
1
|
+
// Backs the Admin UI's official abilities search box: the npm packages
|
|
2
|
+
// under LoopEngine's @loopengine-co scope, tagged with the
|
|
3
|
+
// loopengine-ability keyword (core/official-abilities.ts). A fresh call on
|
|
4
|
+
// every search, no caching: this is an on-demand lookup the operator
|
|
5
|
+
// explicitly asked for, not data the tab needs hot the way
|
|
6
|
+
// gateway-tools.ts's own live-reconnect data is — same "only pay the
|
|
7
|
+
// live cost when actually asked for" split, just with nothing worth
|
|
8
|
+
// remembering afterward here.
|
|
9
|
+
import { isOfficialScope, unofficialAbilitiesAllowed } from '../core/official-abilities.js';
|
|
1
10
|
const NPM_SEARCH_TIMEOUT_MS = 10000;
|
|
2
|
-
|
|
11
|
+
// npm's most per page: the official ones are filtered out of everything
|
|
12
|
+
// tagged with the keyword, so ask for all of it.
|
|
13
|
+
const NPM_SEARCH_SIZE = 250;
|
|
3
14
|
export async function searchPublicAbilities(query) {
|
|
4
15
|
const text = query ? `keywords:loopengine-ability ${query}` : 'keywords:loopengine-ability';
|
|
5
16
|
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(text)}&size=${NPM_SEARCH_SIZE}`;
|
|
@@ -18,7 +29,12 @@ export async function searchPublicAbilities(query) {
|
|
|
18
29
|
if (!res.ok)
|
|
19
30
|
throw new Error(`npm registry search failed (HTTP ${res.status})`);
|
|
20
31
|
const data = (await res.json());
|
|
21
|
-
|
|
32
|
+
// Only LoopEngine's own scope, unless unofficial abilities are turned
|
|
33
|
+
// on: anyone can publish with the keyword. (npm's scope: qualifier
|
|
34
|
+
// only ranks, it doesn't filter.)
|
|
35
|
+
const all = unofficialAbilitiesAllowed();
|
|
36
|
+
const official = (data.objects ?? []).filter((o) => all || isOfficialScope(o.package.name));
|
|
37
|
+
const results = official.map((o) => ({
|
|
22
38
|
name: o.package.name,
|
|
23
39
|
description: o.package.description ?? '',
|
|
24
40
|
version: o.package.version,
|
|
@@ -728,7 +728,7 @@ ${consoleShellScript}
|
|
|
728
728
|
'<span><a href="?agent=' + encodeURIComponent(agentName) + '&tab=schedules#schedule-' + escapeHtml(x.taskId) + '" class="workflow-schedule-link" data-task="' + escapeHtml(x.taskId) + '" title="See it in the Schedules tab">' +
|
|
729
729
|
escapeHtml(x.describe.charAt(0).toUpperCase() + x.describe.slice(1)) + '</a></span>' + state +
|
|
730
730
|
(values ? '<span class="hint">' + values + '</span>' : '') +
|
|
731
|
-
(x.notify ? '<span class="hint">
|
|
731
|
+
(x.notify ? '<span class="hint">messages you the result when it finishes</span>' : '') +
|
|
732
732
|
'<span style="display:flex;gap:6px;margin-left:auto">' +
|
|
733
733
|
(x.status === 'paused' ? '<button type="button" class="workflow-schedule-action" data-id="' + escapeHtml(x.taskId) + '" data-action="resume">Resume</button>'
|
|
734
734
|
: x.status === 'scheduled' ? '<button type="button" class="workflow-schedule-action" data-id="' + escapeHtml(x.taskId) + '" data-action="pause">Pause</button>' : '') +
|
|
@@ -1023,7 +1023,7 @@ ${consoleShellScript}
|
|
|
1023
1023
|
if (t.filter) rules.push('only when ' + escapeHtml(t.filter.path) + ' is ' + t.filter.values.map(escapeHtml).join(' or '));
|
|
1024
1024
|
if (t.mapping) rules.push(Object.keys(t.mapping).map(function (p) { return escapeHtml(p) + ' ' + String.fromCharCode(8592) + ' ' + escapeHtml(t.mapping[p]); }).join(', '));
|
|
1025
1025
|
if (t.payload_param) rules.push('the whole payload goes to ' + escapeHtml(t.payload_param));
|
|
1026
|
-
if (t.notify) rules.push('
|
|
1026
|
+
if (t.notify) rules.push('messages you the result when it finishes');
|
|
1027
1027
|
if (t.callback_url) rules.push('reports to ' + escapeHtml(t.callback_url));
|
|
1028
1028
|
// Made by the workflow planner without its secret: it turns on once
|
|
1029
1029
|
// the sender's signing secret is added.
|
|
@@ -1069,7 +1069,7 @@ ${consoleShellScript}
|
|
|
1069
1069
|
params.map(function (p) { return '<div class="workflow-param-row"><code class="wp-name">' + escapeHtml(p) + '</code><input name="map:' + escapeHtml(p) + '" placeholder="data.object.id" style="flex:1"></div>'; }).join('') + '</div>' +
|
|
1070
1070
|
'<label>Or give the whole payload to <select name="payload_param"><option value="">(none)</option>' + params.map(function (p) { return '<option>' + escapeHtml(p) + '</option>'; }).join('') + '</select></label>'
|
|
1071
1071
|
: '<p class="hint" style="margin:0">This workflow takes no parameters, so the payload isn' + String.fromCharCode(8217) + 't passed on. Add one in Edit to use it.</p>') +
|
|
1072
|
-
'<label class="http-tool-inline-label"><input type="checkbox" name="notify">
|
|
1072
|
+
'<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Message me the result when it finishes <span class="hint">(through its Messaging tab: Slack, email, Lark or a webhook)</span></label>' +
|
|
1073
1073
|
'<label>Post each run' + String.fromCharCode(8217) + 's result to <span class="hint">(optional, an https URL)</span><input name="callback_url" placeholder="https://example.com/hooks/loopengine"></label>' +
|
|
1074
1074
|
'<div style="display:flex;gap:8px"><button type="submit" class="primary">Make the URL</button></div>' +
|
|
1075
1075
|
'<div class="hint hook-form-result"></div></form>';
|
|
@@ -2431,8 +2431,8 @@ ${consoleShellScript}
|
|
|
2431
2431
|
? '<div><span class="le-pill bad">Workflow removed</span> <span class="hint">' + escapeHtml(t.workflow_id) + '</span></div>'
|
|
2432
2432
|
: '<div class="schedule-result hint">' + escapeHtml(t.message) + '</div>') +
|
|
2433
2433
|
(t.status !== 'cancelled' && t.status !== 'done'
|
|
2434
|
-
? '<label class="schedule-notify hint" title="
|
|
2435
|
-
: (t.notify ? '<div class="hint">
|
|
2434
|
+
? '<label class="schedule-notify hint" title="Sent through its Messaging tab: Slack, email, Lark or a webhook"><input type="checkbox" class="schedule-notify-toggle" data-id="' + escapeHtml(t.task_id) + '"' + (t.notify ? ' checked' : '') + '> Message me the result when it finishes</label>'
|
|
2435
|
+
: (t.notify ? '<div class="hint">Messaged you the result when it finished</div>' : '')) + '</td>' +
|
|
2436
2436
|
'<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(fmtScheduleTime(t.next_run_at)) + '</div>' : '') + '</td>' +
|
|
2437
2437
|
'<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
|
|
2438
2438
|
(t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Messaging</a></div>' : '') + '</td>' +
|
|
@@ -2474,7 +2474,7 @@ ${consoleShellScript}
|
|
|
2474
2474
|
'<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
|
|
2475
2475
|
'</div>' +
|
|
2476
2476
|
'<label>Name <span class="hint">(optional)</span><input name="task_name" placeholder="Daily ticket summary"></label>' +
|
|
2477
|
-
'<label class="http-tool-inline-label"><input type="checkbox" name="notify">
|
|
2477
|
+
'<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Message me the result when it finishes <span class="hint">(through its Messaging tab: Slack, email, Lark or a webhook)</span></label>' +
|
|
2478
2478
|
'<div style="display:flex;gap:8px"><button type="submit">Schedule</button></div>' +
|
|
2479
2479
|
'<div id="scheduleResult" class="hint"></div>' +
|
|
2480
2480
|
'</form></section>';
|
|
@@ -3040,7 +3040,7 @@ ${consoleShellScript}
|
|
|
3040
3040
|
}
|
|
3041
3041
|
|
|
3042
3042
|
// ---- Apps tab: what's already installed for this agent, a
|
|
3043
|
-
// search box over
|
|
3043
|
+
// search box over official (@loopengine-co on npm) abilities,
|
|
3044
3044
|
// and a plain spec field for a private ability (a git URL, a local
|
|
3045
3045
|
// file:../path, or a private registry name) — both installs go
|
|
3046
3046
|
// through the same POST .../abilities, since installAbility itself
|
|
@@ -3085,7 +3085,7 @@ ${consoleShellScript}
|
|
|
3085
3085
|
'</tr>';
|
|
3086
3086
|
}
|
|
3087
3087
|
|
|
3088
|
-
//
|
|
3088
|
+
// An official or private ability not installed here yet: its
|
|
3089
3089
|
// details show the permissions it ships with.
|
|
3090
3090
|
function renderAbilitySearchResultRow(pkg, installedNames, spec) {
|
|
3091
3091
|
var installSpec = spec || pkg.name;
|
|
@@ -3108,15 +3108,15 @@ ${consoleShellScript}
|
|
|
3108
3108
|
: '<p class="hint">Nothing added to this agent yet.</p>';
|
|
3109
3109
|
|
|
3110
3110
|
// What this agent has first; then the workspace's connected apps (each
|
|
3111
|
-
// with the tools this agent gets), then
|
|
3111
|
+
// with the tools this agent gets), then official and private abilities.
|
|
3112
3112
|
return '<h3 style="justify-content:flex-start;align-items:baseline;gap:8px">Installed <span class="hint" style="font-weight:400">the apps and abilities this agent has</span></h3>' + installedHtml +
|
|
3113
3113
|
// Installing by name or spec is on the console's Abilities page.
|
|
3114
3114
|
'<p id="abilityInstallStatus" class="hint"></p>' +
|
|
3115
3115
|
'<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Apps <span class="hint" style="font-weight:400">connected in this workspace; choose which of their tools this agent gets, and each one' + String.fromCharCode(8217) + 's permission</span></h3>' +
|
|
3116
3116
|
'<div id="agentApps" style="margin-bottom:32px"><p class="hint">Loading…</p></div>' +
|
|
3117
|
-
'<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">
|
|
3117
|
+
'<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Official abilities <span class="hint" style="font-weight:400">published by LoopEngine</span></h3>' +
|
|
3118
3118
|
'<form id="abilitySearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px">' +
|
|
3119
|
-
'<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search
|
|
3119
|
+
'<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search official abilities" style="flex:1">' +
|
|
3120
3120
|
'<button type="submit">Search</button>' +
|
|
3121
3121
|
'</form>' +
|
|
3122
3122
|
'<div id="abilitySearchResults" style="margin-bottom:32px"></div>' +
|
|
@@ -3334,7 +3334,7 @@ ${consoleShellScript}
|
|
|
3334
3334
|
var results = result.body.results || [];
|
|
3335
3335
|
resultsEl.innerHTML = results.length
|
|
3336
3336
|
? '<table><thead><tr><th>Package</th><th>Description</th><th></th></tr></thead><tbody>' + results.map(function (pkg) { return renderAbilitySearchResultRow(pkg, installedNames); }).join('') + '</tbody></table>'
|
|
3337
|
-
: '<p class="hint">No
|
|
3337
|
+
: '<p class="hint">No official abilities found.</p>';
|
|
3338
3338
|
var installBtns = resultsEl.querySelectorAll('.ability-install-btn');
|
|
3339
3339
|
for (var i = 0; i < installBtns.length; i++) {
|
|
3340
3340
|
installBtns[i].addEventListener('click', function (ev2) {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// leShell.wireModelPicker(form) model dropdown for a provider select
|
|
16
16
|
// leShell.loadProviderKeys() Promise of {provider: key saved?}, or null
|
|
17
17
|
// leShell.applyProviderKeys(form, keys, keep) only providers with a key
|
|
18
|
-
// leShell.abilityOriginBadge(origin) Public/Private badge for an ability
|
|
18
|
+
// leShell.abilityOriginBadge(origin) Official/Public/Private badge for an ability
|
|
19
19
|
// leShell.toggleAbilityDetails(btn) an ability's tools, skills and settings
|
|
20
20
|
// Same embedded-string conventions as the pages themselves: plain
|
|
21
21
|
// string concatenation and no backslashes inside the script, so nothing
|
|
@@ -360,11 +360,12 @@ export const consoleShellScript = `<script>
|
|
|
360
360
|
return decision ? '<span class="le-pill ' + decision + '">' + DECISION_LABELS[decision] + '</span>' : '<span class="hint">No rule</span>';
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
-
// Public (npm) or Private, with where
|
|
363
|
+
// Official (@loopengine-co), Public (other npm) or Private, with where
|
|
364
|
+
// it came from on hover.
|
|
364
365
|
function abilityOriginBadge(origin) {
|
|
365
366
|
if (!origin) return '';
|
|
366
367
|
return '<span class="le-pill ' + (origin.kind === 'private' ? 'accent' : '') + '" title="' + esc(origin.label) + '">' +
|
|
367
|
-
(origin.kind === 'private' ? 'Private' : 'Public') + '</span> <span class="hint">' + esc(origin.label) + '</span>';
|
|
368
|
+
(origin.kind === 'private' ? 'Private' : origin.kind === 'official' ? 'Official' : 'Public') + '</span> <span class="hint">' + esc(origin.label) + '</span>';
|
|
368
369
|
}
|
|
369
370
|
|
|
370
371
|
|
|
@@ -251,7 +251,7 @@ ${consoleShellScript}
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
// ---- Abilities section ----
|
|
254
|
-
// How agents reach outside apps.
|
|
254
|
+
// How agents reach outside apps. Official abilities (npm) are browsed
|
|
255
255
|
// here and installed from an agent's own Apps tab. A private one
|
|
256
256
|
// (from the sources in Settings) is installed here into the workspace
|
|
257
257
|
// (core/workspace-abilities.ts), then added to any agent from its tab.
|
|
@@ -271,9 +271,9 @@ ${consoleShellScript}
|
|
|
271
271
|
function renderAbilities() {
|
|
272
272
|
var content = document.getElementById('abilitiesContent');
|
|
273
273
|
content.innerHTML = '<section>' +
|
|
274
|
-
'<h4>
|
|
274
|
+
'<h4>Official <span class="hint">published by LoopEngine; add one to an agent from its Apps tab</span></h4>' +
|
|
275
275
|
'<form id="abilitySearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px">' +
|
|
276
|
-
'<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search
|
|
276
|
+
'<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search official abilities" style="flex:1">' +
|
|
277
277
|
'<button type="submit">Search</button>' +
|
|
278
278
|
'</form>' +
|
|
279
279
|
'<div id="abilitySearchResults"><p class="hint">Loading…</p></div>' +
|
|
@@ -443,7 +443,7 @@ ${consoleShellScript}
|
|
|
443
443
|
}
|
|
444
444
|
|
|
445
445
|
// Linked from an agent's Apps tab (/config?section=abilities#private-abilities):
|
|
446
|
-
// once every list has loaded, so the
|
|
446
|
+
// once every list has loaded, so the official results don't push it down.
|
|
447
447
|
var scrolledToPrivate = false;
|
|
448
448
|
function scrollToPrivateIfLinked() {
|
|
449
449
|
if (scrolledToPrivate || location.hash !== '#private-abilities' || !lastPrivate || !workspaceAbilities || lastSearch.q === null) return;
|
|
@@ -547,6 +547,8 @@ ${consoleShellScript}
|
|
|
547
547
|
// Open tool panels, by ability: { tools, filter, error, busy }.
|
|
548
548
|
var openTools = {};
|
|
549
549
|
var appSearch = { q: '', servers: null, error: '' };
|
|
550
|
+
// The "Add your own server" form: open, and what's typed in it.
|
|
551
|
+
var ownForm = { open: false };
|
|
550
552
|
|
|
551
553
|
function appLetter(title) {
|
|
552
554
|
return '<span class="app-letter" aria-hidden="true">' + escapeHtml((title || '?').charAt(0).toUpperCase()) + '</span>';
|
|
@@ -572,6 +574,37 @@ ${consoleShellScript}
|
|
|
572
574
|
'<p class="hint app-msg" style="margin:0"></p></form>';
|
|
573
575
|
}
|
|
574
576
|
|
|
577
|
+
// Your company's own servers (mcp-servers.yml): each connects like any
|
|
578
|
+
// app; a saved one not connected can be connected again or removed.
|
|
579
|
+
function renderOwnServers() {
|
|
580
|
+
var saved = apps.saved || [];
|
|
581
|
+
var rows = saved.map(function (s) {
|
|
582
|
+
var connected = apps.connected.some(function (c) { return c.url === s.url; });
|
|
583
|
+
return '<div class="app-row" data-app="' + escapeHtml(s.id) + '"><div class="app-head">' + appLetter(s.title) + '</div>' +
|
|
584
|
+
'<div class="app-row-main"><div><strong>' + escapeHtml(s.title) + '</strong> <span class="badge badge-default">Private</span></div>' +
|
|
585
|
+
'<div class="hint">' + escapeHtml(s.url) + (s.description ? ' · ' + escapeHtml(s.description) : '') + (s.header ? ' · key sent in ' + escapeHtml(s.header) : '') + '</div></div>' +
|
|
586
|
+
'<div class="app-slot">' + (connected
|
|
587
|
+
? '<span class="badge badge-custom">Connected</span>'
|
|
588
|
+
: '<button type="button" class="app-connect" data-id="' + escapeHtml(s.id) + '"' + (apps.canConnect ? '' : ' disabled') + '>Connect</button> <button type="button" class="delete-btn app-forget" data-id="' + escapeHtml(s.id) + '"' + (apps.canConnect ? '' : ' disabled') + '>Remove</button>') + '</div></div>';
|
|
589
|
+
}).join('');
|
|
590
|
+
var form = ownForm.open
|
|
591
|
+
? '<form id="ownServerForm" class="app-form" style="max-width:560px">' +
|
|
592
|
+
'<label>Name<input name="title" required maxlength="60" placeholder="Acme CRM"></label>' +
|
|
593
|
+
'<label>Address<input name="url" type="url" required placeholder="https://mcp.yourcompany.com/mcp"></label>' +
|
|
594
|
+
'<label>What it does <span class="hint">(optional)</span><input name="description" maxlength="200" placeholder="Our customers, orders and support tickets"></label>' +
|
|
595
|
+
'<label>API key <span class="hint">(if it needs one; saved in this workspace' + String.fromCharCode(8217) + 's settings)</span><input name="key" type="password" autocomplete="off"></label>' +
|
|
596
|
+
'<label>Header for the key <span class="hint">(optional; empty sends it as Authorization: Bearer ' + String.fromCharCode(8230) + ')</span><input name="header" placeholder="X-Api-Key" pattern="[A-Za-z0-9-]+"></label>' +
|
|
597
|
+
'<div class="app-foot"><button type="submit">Connect</button><button type="button" class="own-cancel">Cancel</button></div>' +
|
|
598
|
+
'<p class="hint app-msg" style="margin:0"></p></form>'
|
|
599
|
+
: '<button type="button" class="own-add"' + (apps.canConnect ? '' : ' disabled') + '>Add your own server</button>';
|
|
600
|
+
return '<section id="own-servers"><h3>Your own servers</h3>' +
|
|
601
|
+
'<p class="hint">Your company' + String.fromCharCode(8217) + 's own MCP server, by its address: it connects like any app, and only this workspace sees it. Servers that only offer a sign-in (OAuth) can' + String.fromCharCode(8217) + 't be connected yet.</p>' +
|
|
602
|
+
(rows ? '<div class="app-list" style="margin-bottom:10px">' + rows + '</div>' : '') + form + '</section>';
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// By name: form.title is the form's own title attribute.
|
|
606
|
+
function ownField(form, name) { return form.querySelector('[name=' + name + ']').value; }
|
|
607
|
+
|
|
575
608
|
function renderApps() {
|
|
576
609
|
var el = document.getElementById('appsContent');
|
|
577
610
|
if (!apps) return;
|
|
@@ -601,6 +634,7 @@ ${consoleShellScript}
|
|
|
601
634
|
}).join('') + '</div>' +
|
|
602
635
|
'<p class="hint">Each is the app' + String.fromCharCode(8217) + 's own MCP server. Apps that only offer a sign-in (OAuth) can' + String.fromCharCode(8217) + 't be connected yet; their Connect says so.</p>' +
|
|
603
636
|
'</section>' +
|
|
637
|
+
renderOwnServers() +
|
|
604
638
|
'<section><h3>More apps</h3>' +
|
|
605
639
|
'<form id="appSearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px"><input type="search" name="q" placeholder="Search the MCP Registry, e.g. calendly" aria-label="Search apps" style="flex:1" value="' + escapeHtml(appSearch.q) + '"><button type="submit">Search</button></form>' +
|
|
606
640
|
'<div id="appSearchResults">' + renderAppSearch() + '</div>' +
|
|
@@ -716,6 +750,26 @@ ${consoleShellScript}
|
|
|
716
750
|
return;
|
|
717
751
|
}
|
|
718
752
|
if (t.classList.contains('app-cancel')) { renderApps(); return; }
|
|
753
|
+
if (t.classList.contains('own-add')) {
|
|
754
|
+
ownForm.open = true;
|
|
755
|
+
renderApps();
|
|
756
|
+
document.querySelector('#ownServerForm input[name=title]').focus();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
if (t.classList.contains('own-cancel')) { ownForm.open = false; renderApps(); return; }
|
|
760
|
+
if (t.classList.contains('app-forget')) {
|
|
761
|
+
if (t.dataset.confirming !== '1') { t.dataset.confirming = '1'; t.textContent = 'Click again to remove'; return; }
|
|
762
|
+
t.disabled = true;
|
|
763
|
+
fetch('/config/apps/servers?id=' + encodeURIComponent(t.dataset.id), { method: 'DELETE' })
|
|
764
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
765
|
+
.then(function (r) {
|
|
766
|
+
if (!r.ok) throw new Error(r.body.error || 'request failed');
|
|
767
|
+
apps = r.body;
|
|
768
|
+
renderApps();
|
|
769
|
+
})
|
|
770
|
+
.catch(function (err) { t.disabled = false; t.textContent = 'Could not remove: ' + err.message; });
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
719
773
|
if (t.classList.contains('app-disconnect')) {
|
|
720
774
|
if (t.dataset.confirming !== '1') { t.dataset.confirming = '1'; t.textContent = 'Click again to disconnect'; return; }
|
|
721
775
|
t.disabled = true;
|
|
@@ -760,6 +814,28 @@ ${consoleShellScript}
|
|
|
760
814
|
.catch(function (err) { appSearch.error = 'Could not search: ' + err.message; document.getElementById('appSearchResults').innerHTML = renderAppSearch(); });
|
|
761
815
|
return;
|
|
762
816
|
}
|
|
817
|
+
if (form.id === 'ownServerForm') {
|
|
818
|
+
var ownBtn = form.querySelector('button[type=submit]');
|
|
819
|
+
var ownMsg = form.querySelector('.app-msg');
|
|
820
|
+
ownBtn.disabled = true;
|
|
821
|
+
ownMsg.className = 'hint app-msg';
|
|
822
|
+
ownMsg.textContent = 'Connecting' + String.fromCharCode(8230) + ' it asks the server for its tools and sets their permissions, which can take a minute.';
|
|
823
|
+
fetch('/config/apps/servers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: ownField(form, 'title'), url: ownField(form, 'url'), description: ownField(form, 'description'), key: ownField(form, 'key'), header: ownField(form, 'header') }) })
|
|
824
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
825
|
+
.then(function (r) {
|
|
826
|
+
if (!r.ok) throw new Error(r.body.error || 'request failed');
|
|
827
|
+
apps = r.body;
|
|
828
|
+
ownForm.open = false;
|
|
829
|
+
if (r.body.connectedAbility) loadTools(r.body.connectedAbility, false);
|
|
830
|
+
else renderApps();
|
|
831
|
+
})
|
|
832
|
+
.catch(function (err) {
|
|
833
|
+
ownBtn.disabled = false;
|
|
834
|
+
ownMsg.className = 'error app-msg';
|
|
835
|
+
ownMsg.textContent = err.message;
|
|
836
|
+
});
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
763
839
|
if (!form.dataset.connect) return;
|
|
764
840
|
var btn = form.querySelector('button[type=submit]');
|
|
765
841
|
var msg = form.querySelector('.app-msg');
|
|
@@ -805,12 +881,12 @@ ${consoleShellScript}
|
|
|
805
881
|
rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') +
|
|
806
882
|
'</tbody></table></section>' +
|
|
807
883
|
(d.timeZone ? '<section><h3>Time zone</h3><div class="source" id="timeZoneBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' : '') +
|
|
808
|
-
'<section><h3>Private ability sources</h3><p class="muted" style="margin:0 0 10px">Where your own abilities come from, listed under Private on the Abilities page next to
|
|
884
|
+
'<section><h3>Private ability sources</h3><p class="muted" style="margin:0 0 10px">Where your own abilities come from, listed under Private on the Abilities page next to LoopEngine's official ones.</p><div class="source" id="abilitySourcesBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' +
|
|
809
885
|
'<section><h3>Updates</h3><div class="source" id="upgradeBox"><p class="hint">Checking for a newer LoopEngine' + String.fromCharCode(8230) + '</p></div></section>';
|
|
810
886
|
}
|
|
811
887
|
|
|
812
888
|
// ---- Private ability sources (Settings) ----
|
|
813
|
-
// Where this project's own abilities come from, next to the
|
|
889
|
+
// Where this project's own abilities come from, next to the official ones
|
|
814
890
|
// on npm: a GitHub org or user, or a private npm registry
|
|
815
891
|
// (core/ability-sources.ts). There's no folder kind here: this server
|
|
816
892
|
// may run in the cloud, so local files are uploaded on the Abilities
|
|
@@ -15,6 +15,8 @@ import { ActauthRuleExistsError, addActauthRule, applyPlannedRules, setDefaultDe
|
|
|
15
15
|
import { setAgentEnvVar } from './env-admin.js';
|
|
16
16
|
import { freeAgentName } from './interview-admin.js';
|
|
17
17
|
import { writeSkill } from './skills-admin.js';
|
|
18
|
+
import { agentDir } from '../core/gateway-tools.js';
|
|
19
|
+
import { setWorkflowFields } from '../core/workflows.js';
|
|
18
20
|
/** What a template's schedule runs (its workflow, when it comes from one,
|
|
19
21
|
* or its message), and whether it sends each result. */
|
|
20
22
|
function scheduleRuns(schedule) {
|
|
@@ -84,7 +86,7 @@ export function previewTemplate(id, deps) {
|
|
|
84
86
|
// A key set for the whole project already counts for a new agent.
|
|
85
87
|
keys: keyGroups(template).map((names) => ({ names, set: names.some((n) => Boolean(envValue(name, n))) })),
|
|
86
88
|
settings: setup.env ?? {},
|
|
87
|
-
schedule: setup.schedule ? `${describeTemplateSchedule(setup.schedule)} (created paused)` : null,
|
|
89
|
+
schedule: setup.schedule ? `${describeTemplateSchedule(setup.schedule)} (created paused)${setup.schedule.notify ? '; it messages you the result when it finishes, through its Messaging tab: Slack, email, Lark or a webhook' : ''}` : null,
|
|
88
90
|
needsApprovals: setup.needsApprovals,
|
|
89
91
|
notes: setup.notes ?? [],
|
|
90
92
|
};
|
|
@@ -104,8 +106,19 @@ export function templateChecklist(template, name, deps, failed = [], appsToConne
|
|
|
104
106
|
for (const group of missing.filter((g) => g.length > 1)) {
|
|
105
107
|
items.push({ label: `Add one of ${group.slice(0, -1).join(', ')} or ${group.at(-1)}`, href: tab('env') });
|
|
106
108
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
// A schedule or webhook that sends each result needs the same connection.
|
|
110
|
+
const webhookWorkflows = (template.setup.skills ?? []).filter((s) => s.workflow?.webhook);
|
|
111
|
+
const sendsResults = Boolean(template.setup.schedule?.notify) || webhookWorkflows.length > 0;
|
|
112
|
+
if ((template.setup.needsApprovals || sendsResults) && !deps.messagingConfigured(name)) {
|
|
113
|
+
items.push({
|
|
114
|
+
label: template.setup.needsApprovals
|
|
115
|
+
? `Choose where approval requests${sendsResults ? ' and results' : ''} go: Slack, email, Lark or a webhook`
|
|
116
|
+
: 'Choose where its results go: Slack, email, Lark or a webhook',
|
|
117
|
+
href: tab('messaging'),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
for (const skill of webhookWorkflows) {
|
|
121
|
+
items.push({ label: `Add a webhook to the "${skill.workflow.title}" workflow with "Message me the result when it finishes" on, then paste its URL into the app that sends the alerts`, href: tab('workflows') });
|
|
109
122
|
}
|
|
110
123
|
for (const note of template.setup.notes ?? [])
|
|
111
124
|
items.push({ label: note });
|
|
@@ -179,8 +192,11 @@ export async function createFromTemplateObject(template, deps) {
|
|
|
179
192
|
// none twice, a stricter one placed so it counts, none loosened.
|
|
180
193
|
applyPlannedRules(name, setup.rules.map((r) => ({ name: r.name, tool: r.tool, decision: r.decision })));
|
|
181
194
|
// 3. Its own skills and non-secret settings.
|
|
182
|
-
for (const skill of setup.skills ?? [])
|
|
195
|
+
for (const skill of setup.skills ?? []) {
|
|
183
196
|
writeSkill(name, skill.id, { description: skill.description, body: skill.body });
|
|
197
|
+
if (skill.workflow)
|
|
198
|
+
setWorkflowFields(agentDir(name), skill.id, { title: skill.workflow.title, instructions: skill.body, ...(skill.workflow.parameters ? { parameters: skill.workflow.parameters } : {}) });
|
|
199
|
+
}
|
|
184
200
|
for (const [variable, value] of Object.entries(setup.env ?? {}))
|
|
185
201
|
await setAgentEnvVar(name, variable, value);
|
|
186
202
|
// 4. Load it, now that everything is in place.
|
|
@@ -75,7 +75,7 @@ ${consoleShellScript}
|
|
|
75
75
|
'<div class="meta">' +
|
|
76
76
|
'<div><strong>Runs:</strong> ' + esc(t.trigger) + '</div>' +
|
|
77
77
|
(t.asksFirst.length ? '<div><strong>Asks you first:</strong> ' + esc(t.asksFirst.join(', ')) + '</div>' : '') +
|
|
78
|
-
'<div><strong>
|
|
78
|
+
(t.abilities.length || (t.apps || []).length ? '<div><strong>Uses:</strong> ' + t.abilities.map(function (a) { return '<code>' + esc(a) + '</code>'; }).concat((t.apps || []).map(function (a) { return esc(a); })).join(' ') + '</div>' : '') +
|
|
79
79
|
'</div>' +
|
|
80
80
|
'<div class="row"><button type="button" class="primary tp-use">Use this template</button></div>' +
|
|
81
81
|
'<div class="tp-slot"></div></div>';
|
|
@@ -108,17 +108,18 @@ ${consoleShellScript}
|
|
|
108
108
|
slot.innerHTML = '<div class="tp-preview">' +
|
|
109
109
|
'<div><strong>Agent:</strong> <code>' + esc(p.name) + '</code></div>' +
|
|
110
110
|
'<div><strong>Instructions</strong> <span class="hint">(edit them later in its Overview tab)</span><pre>' + esc(p.systemPrompt) + '</pre></div>' +
|
|
111
|
-
'<div><strong>Installs</strong> <span class="hint">(abilities: their tools, skills and permission rules)</span>' +
|
|
111
|
+
(p.abilities.length ? '<div><strong>Installs</strong> <span class="hint">(abilities: their tools, skills and permission rules)</span>' +
|
|
112
112
|
listHtml(p.abilities.map(function (a) { return '<li><code>' + esc(a) + '</code></li>'; })) +
|
|
113
|
-
(canInstall ? '' : '<p class="error" style="margin:4px 0 0">Installing abilities needs <code>LOOPENGINE_ADMIN_AUTH</code> set on the server. Without it, the agent is created and its abilities head the checklist.</p>') + '</div>' +
|
|
113
|
+
(canInstall ? '' : '<p class="error" style="margin:4px 0 0">Installing abilities needs <code>LOOPENGINE_ADMIN_AUTH</code> set on the server. Without it, the agent is created and its abilities head the checklist.</p>') + '</div>' : '') +
|
|
114
114
|
(p.apps && p.apps.length ? '<div><strong>Apps it uses</strong> <span class="hint">(connected on the <a href="/config?section=apps">Apps</a> page; one that isn' + String.fromCharCode(8217) + 't connected yet heads the checklist)</span>' + listHtml(p.apps.map(function (a) { return '<li>' + esc(a) + '</li>'; })) + '</div>' : '') +
|
|
115
115
|
'<div><strong>Permissions</strong> <span class="hint">(anything not listed asks you first)</span>' +
|
|
116
116
|
listHtml(p.rules.map(function (rule) { return '<li><code>' + esc(rule.tool) + '</code>: ' + esc(DECISIONS[rule.decision] || rule.decision) + '</li>'; })) + '</div>' +
|
|
117
|
-
'<div><strong>
|
|
117
|
+
(p.skills && p.skills.length ? '<div><strong>Skills it starts with</strong> <span class="hint">(edit them later in Advanced ' + String.fromCharCode(8594) + ' Skills)</span>' + listHtml(p.skills.map(function (sk) { return '<li><code>' + esc(sk) + '</code></li>'; })) + '</div>' : '') +
|
|
118
|
+
(p.keys.length ? '<div><strong>Keys it needs</strong>' +
|
|
118
119
|
listHtml(p.keys.map(function (k) {
|
|
119
120
|
var names = k.names.length > 1 ? 'one of ' + k.names.join(', ') : k.names[0];
|
|
120
121
|
return '<li>' + esc(names) + (k.set ? ' <span class="hint">' + CHECK + ' already set</span>' : '') + '</li>';
|
|
121
|
-
})) + '</div>' +
|
|
122
|
+
})) + '</div>' : '') +
|
|
122
123
|
(settings.length ? '<div><strong>Settings filled in:</strong> ' + settings.map(function (s) { return '<code>' + esc(s) + '=' + esc(p.settings[s]) + '</code>'; }).join(', ') + '</div>' : '') +
|
|
123
124
|
(p.schedule ? '<div><strong>Schedule:</strong> ' + esc(p.schedule) + '</div>' : '') +
|
|
124
125
|
(p.needsApprovals ? '<div><strong>Approvals:</strong> requests reach you wherever its Messaging tab sends them (Slack, email, Lark or a webhook).</div>' : '') +
|
|
@@ -263,7 +263,7 @@ ${consoleShellScript}
|
|
|
263
263
|
'<p class="hint" style="margin:0"><strong>Starts:</strong> ' + esc(p.trigger.description) + (p.trigger.kind === 'schedule' ? ' (created paused; you turn it on)' : '') + '</p>' +
|
|
264
264
|
webhookPlanHtml(p) +
|
|
265
265
|
(p.trigger.kind === 'schedule' || p.trigger.kind === 'webhook'
|
|
266
|
-
? '<label class="wf-notify"><input type="checkbox" id="wfNotify"' + (p.notify && p.notify.on ? ' checked' : '') + '>
|
|
266
|
+
? '<label class="wf-notify"><input type="checkbox" id="wfNotify"' + (p.notify && p.notify.on ? ' checked' : '') + '> Message me the result when it finishes <span class="hint">(through its Messaging tab: Slack, email, Lark or a webhook)</span></label>' +
|
|
267
267
|
(p.notify && p.notify.reason ? '<p class="hint" style="margin:0 0 0 24px">' + esc(p.notify.reason) + '</p>' : '')
|
|
268
268
|
: '') +
|
|
269
269
|
paramsSectionHtml(p) +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"dist/core/usage.d.ts",
|
|
49
49
|
"dist/core/run-events.js",
|
|
50
50
|
"dist/core/run-events.d.ts",
|
|
51
|
+
"dist/core/official-abilities.js",
|
|
52
|
+
"dist/core/official-abilities.d.ts",
|
|
51
53
|
"dist/core/email-senders.js",
|
|
52
54
|
"dist/core/email-senders.d.ts",
|
|
53
55
|
"dist/core/notifier-file.js",
|