manychat-ai-agent 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.21.0](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.20.0...v0.21.0) (2026-10-07)
4
+
5
+
6
+ ### Features
7
+
8
+ * start a plugin from the agent's own example with agent plugin new (spec 041) ([6aae801](https://github.com/pedronastasi/manychat-ai-agent/commit/6aae8019080718c497f7d031b5cd3d8efe349809))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * write the plugin templates in words no vertical owns (spec 041, 042) ([697b961](https://github.com/pedronastasi/manychat-ai-agent/commit/697b9618171e3e8fb6be23818e4833c73db1449e))
14
+
15
+
16
+ ### Specs and Docs
17
+
18
+ * name agent plugin new as 033 V4's one exception (spec 041) ([516294a](https://github.com/pedronastasi/manychat-ai-agent/commit/516294a5acc98b0690a315d52d36f7da2445b56d))
19
+
3
20
  ## [0.20.0](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.19.1...v0.20.0) (2026-10-07)
4
21
 
5
22
 
package/dist/cli/run.js CHANGED
@@ -11,7 +11,9 @@ export const COMMANDS = {
11
11
  'insights review': 'Approve, edit, reject or retire playbook insights',
12
12
  'insights activate': 'Put a playbook version live once its eval passes',
13
13
  'insights report': 'Enrolment rate by playbook version',
14
+ 'plugin new': 'Start a plugin from the agent’s example (--read or --write)',
14
15
  };
16
+ const PLUGIN_NEW_USAGE = 'Usage: agent plugin new <name> --read|--write';
15
17
  function usage() {
16
18
  const lines = ['Usage: agent <command>', ''];
17
19
  for (const [cmd, desc] of Object.entries(COMMANDS)) {
@@ -70,8 +72,10 @@ export async function run(argv) {
70
72
  const rest = args.slice(cmd.split(' ').length);
71
73
  const configDir = process.env.CONFIG_DIR ?? 'config';
72
74
  // `upgrade` exists to rewrite a config the installed version cannot parse
73
- // yet, so it checks after migrating rather than before.
74
- if (cmd !== 'upgrade' && !configIsValid(configDir))
75
+ // yet, so it checks after migrating rather than before. `plugin new` reads
76
+ // no config but `plugins.json`, and ends by printing the full check
77
+ // (specs/041).
78
+ if (cmd !== 'upgrade' && cmd !== 'plugin new' && !configIsValid(configDir))
75
79
  return 1;
76
80
  switch (cmd) {
77
81
  case 'serve': {
@@ -132,6 +136,8 @@ export async function run(argv) {
132
136
  const { runInsights } = await import("../learning/commands.js");
133
137
  return runInsights([cmd.split(' ')[1], ...rest]);
134
138
  }
139
+ case 'plugin new':
140
+ return pluginNew(rest);
135
141
  case 'tokens backfill': {
136
142
  process.argv = ['node', 'backfill', ...rest];
137
143
  await import("../backfill.js");
@@ -140,6 +146,42 @@ export async function run(argv) {
140
146
  }
141
147
  return usage();
142
148
  }
149
+ /** `agent plugin new <name> --read|--write`, run from the project's root (specs/041). */
150
+ async function pluginNew(args) {
151
+ const flags = args.filter(arg => arg.startsWith('-'));
152
+ const positional = args.filter(arg => !arg.startsWith('-'));
153
+ const kinds = flags.filter(flag => flag === '--read' || flag === '--write');
154
+ // Which kind of tool a plugin adds is the author's decision, never a default.
155
+ if (kinds.length !== 1 || flags.length !== 1 || positional.length !== 1) {
156
+ console.error(PLUGIN_NEW_USAGE);
157
+ return 2;
158
+ }
159
+ const { scaffoldPlugin, ScaffoldError } = await import("../plugins/scaffold.js");
160
+ let made;
161
+ try {
162
+ made = await scaffoldPlugin({
163
+ root: process.cwd(),
164
+ name: positional[0],
165
+ kind: kinds[0] === '--read' ? 'read' : 'write',
166
+ });
167
+ }
168
+ catch (error) {
169
+ if (error instanceof ScaffoldError) {
170
+ console.error(`plugin new: ${error.message}`);
171
+ return 1;
172
+ }
173
+ throw error;
174
+ }
175
+ console.log([
176
+ `created ${made.directory}: plugin ${made.pluginName}, ${made.kind} tool ${made.toolName}`,
177
+ 'added it to pnpm-workspace.yaml, package.json and config/plugins.json',
178
+ '',
179
+ 'Next, install it and run the check the server runs at startup:',
180
+ ' pnpm install',
181
+ ' pnpm agent config check',
182
+ ].join('\n'));
183
+ return 0;
184
+ }
143
185
  /** The outbox and nudge workers without the HTTP server (specs/033). */
144
186
  async function startWorkers(configDir) {
145
187
  const dbMod = await import("../db/client.js");
@@ -1,11 +1,30 @@
1
1
  import { ConfigError } from '../config/loader.ts';
2
+ import type { PluginReadTool, PluginTool } from './api.ts';
2
3
  import { Plugins } from './plugins.ts';
3
4
  /** The optional tenant file that lists the plugin packages to load (specs/036). */
4
5
  export declare const PLUGINS_FILE = "plugins.json";
6
+ /** Names the model already knows: the six of specs/012 and the read of specs/024. */
7
+ export declare const BUILT_IN_TOOLS: ReadonlySet<string>;
8
+ /** A tool name the loader accepts: lowercase snake case, at most 64 characters. */
9
+ export declare const TOOL_NAME: RegExp;
5
10
  /** A plugin that cannot be loaded. A `ConfigError`, so every command reports it as one. */
6
11
  export declare class PluginError extends ConfigError {
7
12
  constructor(message: string);
8
13
  }
14
+ /** The directory of `name` in the first `node_modules` above `from`, as Node finds a package. */
15
+ export declare function packageDir(name: string, from: string): string | undefined;
16
+ /** The file a bare import of the package in `dir` loads. */
17
+ export declare function entryOf(dir: string): string;
18
+ type CheckedTool = PluginTool | PluginReadTool;
19
+ /**
20
+ * Checks a plugin's default export. Everything that would leave the prompt
21
+ * promising an action nothing performs fails here, at startup (C6).
22
+ * `agent plugin new` runs it on what it generates (specs/041).
23
+ */
24
+ export declare function checkPlugin(spec: string, value: unknown, taken: Map<string, string>): {
25
+ name: string;
26
+ tools: CheckedTool[];
27
+ };
9
28
  /** The packages `plugins.json` lists, or none when the file is absent. */
10
29
  export declare function listedPlugins(configDir: string): string[];
11
30
  /**
@@ -18,3 +37,4 @@ export declare function listedPlugins(configDir: string): string[];
18
37
  export declare function loadPlugins(configDir: string, options?: {
19
38
  from?: string;
20
39
  }): Promise<Plugins>;
40
+ export {};
@@ -19,8 +19,12 @@ import { Plugins } from "./plugins.js";
19
19
  export const PLUGINS_FILE = 'plugins.json';
20
20
  const PluginsFile = z.object({ plugins: z.array(z.string().min(1)) }).strict();
21
21
  /** Names the model already knows: the six of specs/012 and the read of specs/024. */
22
- const BUILT_IN = new Set([...ToolName.options, 'get_contact']);
23
- const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
22
+ export const BUILT_IN_TOOLS = new Set([
23
+ ...ToolName.options,
24
+ 'get_contact',
25
+ ]);
26
+ /** A tool name the loader accepts: lowercase snake case, at most 64 characters. */
27
+ export const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
24
28
  const PLUGIN_KEYS = new Set(['name', 'apiVersion', 'tools']);
25
29
  const TOOL_KEYS = new Set(['name', 'description', 'parameters', 'perform', 'read', 'result']);
26
30
  const ENUM_KEYS = new Set(['type', 'values', 'description', 'optional']);
@@ -59,7 +63,7 @@ function unknownKeys(value, allowed) {
59
63
  return Object.keys(value).filter(key => !allowed.has(key));
60
64
  }
61
65
  /** The directory of `name` in the first `node_modules` above `from`, as Node finds a package. */
62
- function packageDir(name, from) {
66
+ export function packageDir(name, from) {
63
67
  let dir = resolve(from);
64
68
  for (;;) {
65
69
  const candidate = join(dir, 'node_modules', name);
@@ -94,7 +98,8 @@ function exported(target) {
94
98
  }
95
99
  return undefined;
96
100
  }
97
- function entryOf(dir) {
101
+ /** The file a bare import of the package in `dir` loads. */
102
+ export function entryOf(dir) {
98
103
  const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
99
104
  const pkg = isObject(manifest) ? manifest : {};
100
105
  const target = exported(pkg.exports) ?? (typeof pkg.main === 'string' ? pkg.main : undefined) ?? 'index.js';
@@ -212,8 +217,9 @@ function checkTool(plugin, apiVersion, value, taken) {
212
217
  throw new PluginError(`plugin ${plugin}: tool name ${JSON.stringify(name)} must be lowercase snake case`);
213
218
  }
214
219
  const where = `plugin ${plugin}, tool ${name}`;
215
- if (BUILT_IN.has(name))
220
+ if (BUILT_IN_TOOLS.has(name)) {
216
221
  throw new PluginError(`${where}: takes the name of a built-in tool`);
222
+ }
217
223
  const owner = taken.get(name);
218
224
  if (owner !== undefined)
219
225
  throw new PluginError(`${where}: plugin ${owner} already defines it`);
@@ -267,8 +273,9 @@ function checkTool(plugin, apiVersion, value, taken) {
267
273
  /**
268
274
  * Checks a plugin's default export. Everything that would leave the prompt
269
275
  * promising an action nothing performs fails here, at startup (C6).
276
+ * `agent plugin new` runs it on what it generates (specs/041).
270
277
  */
271
- function checkPlugin(spec, value, taken) {
278
+ export function checkPlugin(spec, value, taken) {
272
279
  if (!isObject(value)) {
273
280
  throw new PluginError(`plugin ${spec}: its default export is not a plugin (use definePlugin)`);
274
281
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `agent plugin new`: a plugin package started from the agent's own invented
3
+ * example, and the three edits that make the project load it (specs/041).
4
+ */
5
+ export type PluginKind = 'read' | 'write';
6
+ /** The prefix every scaffolded package name carries. */
7
+ export declare const PLUGIN_PREFIX = "agent-plugin-";
8
+ /**
9
+ * The templates, read from the installed agent and never from the project.
10
+ * The path holds from `src/plugins/` and from `dist/plugins/` alike, and
11
+ * `package.json` ships the directory (specs/041 § Verification 5).
12
+ */
13
+ export declare const TEMPLATES_DIR: string;
14
+ export declare const TEMPLATE_FILES: Record<PluginKind, string>;
15
+ /** A refusal: the project is left as it was found, and the command exits 1. */
16
+ export declare class ScaffoldError extends Error {
17
+ constructor(message: string);
18
+ }
19
+ export interface PluginNames {
20
+ /** The package name: `agent-plugin-<name>`. */
21
+ packageName: string;
22
+ /** The plugin's `name`: the part after the prefix. */
23
+ pluginName: string;
24
+ /** Its one tool's name: that part in snake case. */
25
+ toolName: string;
26
+ }
27
+ /** The names `<name>` derives, or a refusal naming why it cannot be one. */
28
+ export declare function pluginNames(name: string): PluginNames;
29
+ /**
30
+ * The project's `pnpm-workspace.yaml` with `plugins/*` among its packages, or
31
+ * undefined when it lists them already. Edited as text, so its comments and
32
+ * every other setting survive.
33
+ */
34
+ export declare function withWorkspaceGlob(text: string | undefined): string | undefined;
35
+ /** The template for `kind`, filled with the plugin's names and the installed agent's API version. */
36
+ export declare function renderTemplate(kind: PluginKind, names: PluginNames): string;
37
+ export interface ScaffoldResult extends PluginNames {
38
+ kind: PluginKind;
39
+ /** The plugin's directory, relative to the project. */
40
+ directory: string;
41
+ }
42
+ /**
43
+ * Writes `plugins/<name>/` and makes the three edits that load it, or refuses
44
+ * and leaves the project as it found it. Installs nothing. `write` is the
45
+ * file write, replaceable so a test can fail one part-way.
46
+ */
47
+ export declare function scaffoldPlugin(options: {
48
+ root: string;
49
+ name: string;
50
+ kind: PluginKind;
51
+ write?: (path: string, content: string) => void;
52
+ }): Promise<ScaffoldResult>;
@@ -0,0 +1,302 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+ import { PLUGIN_API_VERSION, SUPPORTED_PLUGIN_API_VERSIONS } from "./api.js";
13
+ import { BUILT_IN_TOOLS, PLUGINS_FILE, TOOL_NAME, checkPlugin, entryOf, listedPlugins, packageDir, } from "./loader.js";
14
+ import { ConfigError } from "../config/loader.js";
15
+ /** The prefix every scaffolded package name carries. */
16
+ export const PLUGIN_PREFIX = 'agent-plugin-';
17
+ /**
18
+ * The templates, read from the installed agent and never from the project.
19
+ * The path holds from `src/plugins/` and from `dist/plugins/` alike, and
20
+ * `package.json` ships the directory (specs/041 § Verification 5).
21
+ */
22
+ export const TEMPLATES_DIR = fileURLToPath(new URL('../../src/plugins/templates/', import.meta.url));
23
+ export const TEMPLATE_FILES = {
24
+ read: 'read.js.tmpl',
25
+ write: 'write.js.tmpl',
26
+ };
27
+ /** The globs `pnpm-workspace.yaml` must list for `plugins/<name>` to be a workspace package. */
28
+ const WORKSPACE_FILE = 'pnpm-workspace.yaml';
29
+ const WORKSPACE_GLOB = 'plugins/*';
30
+ /** An npm package name in lowercase kebab case, unscoped. */
31
+ const PACKAGE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
32
+ const MAX_PACKAGE_NAME = 214;
33
+ /** A refusal: the project is left as it was found, and the command exits 1. */
34
+ export class ScaffoldError extends Error {
35
+ constructor(message) {
36
+ super(message);
37
+ this.name = 'ScaffoldError';
38
+ }
39
+ }
40
+ /** The names `<name>` derives, or a refusal naming why it cannot be one. */
41
+ export function pluginNames(name) {
42
+ const packageName = name.startsWith(PLUGIN_PREFIX) ? name : `${PLUGIN_PREFIX}${name}`;
43
+ const pluginName = packageName.slice(PLUGIN_PREFIX.length);
44
+ if (!PACKAGE_NAME.test(packageName) ||
45
+ pluginName.length === 0 ||
46
+ packageName.length > MAX_PACKAGE_NAME) {
47
+ throw new ScaffoldError(`${JSON.stringify(name)} is not a package name in lowercase kebab case, such as slot-finder`);
48
+ }
49
+ const toolName = pluginName.replaceAll('-', '_');
50
+ if (!TOOL_NAME.test(toolName)) {
51
+ throw new ScaffoldError(`${packageName} would declare the tool ${toolName}, which the loader refuses: ` +
52
+ 'a tool name starts with a letter and is at most 64 characters');
53
+ }
54
+ return { packageName, pluginName, toolName };
55
+ }
56
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
57
+ /** A file's text, or undefined when it does not exist. */
58
+ function textOf(path) {
59
+ return existsSync(path) ? readFileSync(path, 'utf8') : undefined;
60
+ }
61
+ /** The project's `package.json`, refused unless it depends on the agent. */
62
+ function projectManifest(root) {
63
+ const notProject = (why) => new ScaffoldError(`${root} is not a tenant project: ${why}. Run this from the project's root`);
64
+ const text = textOf(join(root, 'package.json'));
65
+ if (text === undefined)
66
+ throw notProject('it has no package.json');
67
+ let manifest;
68
+ try {
69
+ manifest = JSON.parse(text);
70
+ }
71
+ catch {
72
+ throw notProject('its package.json is not JSON');
73
+ }
74
+ if (!isObject(manifest))
75
+ throw notProject('its package.json is not an object');
76
+ const depends = [manifest.dependencies, manifest.devDependencies].some(deps => isObject(deps) && 'manychat-ai-agent' in deps);
77
+ if (!depends)
78
+ throw notProject('its package.json does not depend on manychat-ai-agent');
79
+ const config = join(root, 'config');
80
+ if (!existsSync(config) || !statSync(config).isDirectory())
81
+ throw notProject('it has no config/');
82
+ return { manifest, text };
83
+ }
84
+ /**
85
+ * The project's `pnpm-workspace.yaml` with `plugins/*` among its packages, or
86
+ * undefined when it lists them already. Edited as text, so its comments and
87
+ * every other setting survive.
88
+ */
89
+ export function withWorkspaceGlob(text) {
90
+ if (text === undefined)
91
+ return `packages:\n - ${WORKSPACE_GLOB}\n`;
92
+ const lines = text.split('\n');
93
+ const item = /^\s*-\s*(['"]?)plugins\/\*\1\s*(?:#.*)?$/;
94
+ if (lines.some(line => item.test(line)))
95
+ return undefined;
96
+ const header = lines.findIndex(line => /^packages:\s*(?:#.*)?$/.test(line));
97
+ if (header === -1) {
98
+ if (lines.some(line => line.startsWith('packages:'))) {
99
+ throw new ScaffoldError(`${WORKSPACE_FILE}: packages is not a block list, so ${WORKSPACE_GLOB} cannot be added to it; ` +
100
+ 'write it as one, an entry per line');
101
+ }
102
+ const base = text.length === 0 || text.endsWith('\n') ? text : `${text}\n`;
103
+ return `${base}packages:\n - ${WORKSPACE_GLOB}\n`;
104
+ }
105
+ const next = lines.slice(header + 1).find(line => line.trim() !== '' && !/^\s*#/.test(line));
106
+ const indent = next?.match(/^(\s*)-\s/)?.[1] ?? ' ';
107
+ lines.splice(header + 1, 0, `${indent}- ${WORKSPACE_GLOB}`);
108
+ return lines.join('\n');
109
+ }
110
+ /** The project's `package.json` with the plugin as a workspace dependency, in its own indent. */
111
+ function withDependency(manifest, text, name) {
112
+ const indent = text.match(/^\{\r?\n([ \t]+)"/)?.[1] ?? ' ';
113
+ const deps = isObject(manifest.dependencies) ? manifest.dependencies : {};
114
+ const sorted = Object.fromEntries(Object.entries({ ...deps, [name]: 'workspace:*' }).sort(([left], [right]) => left.localeCompare(right)));
115
+ return `${JSON.stringify({ ...manifest, dependencies: sorted }, null, indent)}\n`;
116
+ }
117
+ /** Where a listed package's source is: its installed copy, or its workspace directory. */
118
+ function sourceOf(root, name) {
119
+ const installed = packageDir(name, root);
120
+ if (installed)
121
+ return installed;
122
+ const plugins = join(root, 'plugins');
123
+ if (!existsSync(plugins))
124
+ return undefined;
125
+ for (const dir of readdirSync(plugins)) {
126
+ const manifest = join(plugins, dir, 'package.json');
127
+ if (!existsSync(manifest))
128
+ continue;
129
+ try {
130
+ const parsed = JSON.parse(readFileSync(manifest, 'utf8'));
131
+ if (isObject(parsed) && parsed.name === name)
132
+ return join(plugins, dir);
133
+ }
134
+ catch {
135
+ // Not this package; `config check` reports a broken one.
136
+ }
137
+ }
138
+ return undefined;
139
+ }
140
+ /**
141
+ * The tool and plugin names the listed plugins already declare. One that does
142
+ * not load is skipped: it is not this command's to refuse, and `config check`
143
+ * reports it.
144
+ */
145
+ async function declaredNames(root, listed) {
146
+ const tools = new Map();
147
+ const plugins = new Set();
148
+ for (const name of listed) {
149
+ const dir = sourceOf(root, name);
150
+ if (!dir)
151
+ continue;
152
+ let plugin;
153
+ try {
154
+ plugin = (await import(__rewriteRelativeImportExtension(pathToFileURL(entryOf(dir)).href))).default;
155
+ }
156
+ catch {
157
+ continue;
158
+ }
159
+ if (!isObject(plugin))
160
+ continue;
161
+ if (typeof plugin.name === 'string')
162
+ plugins.add(plugin.name);
163
+ if (!Array.isArray(plugin.tools))
164
+ continue;
165
+ for (const tool of plugin.tools) {
166
+ if (isObject(tool) && typeof tool.name === 'string')
167
+ tools.set(tool.name, name);
168
+ }
169
+ }
170
+ return { tools, plugins };
171
+ }
172
+ /** The template for `kind`, filled with the plugin's names and the installed agent's API version. */
173
+ export function renderTemplate(kind, names) {
174
+ // A read tool needs the newest API; a write tool loads under every
175
+ // supported one, so under an agent one release older too.
176
+ const apiVersion = kind === 'read' ? PLUGIN_API_VERSION : Math.min(...SUPPORTED_PLUGIN_API_VERSIONS);
177
+ const source = readFileSync(join(TEMPLATES_DIR, TEMPLATE_FILES[kind]), 'utf8')
178
+ .replaceAll('__PLUGIN_NAME__', names.pluginName)
179
+ .replaceAll('__TOOL_NAME__', names.toolName)
180
+ .replaceAll('__API_VERSION__', String(apiVersion));
181
+ const left = source.match(/__[A-Z_]+__/);
182
+ if (left)
183
+ throw new ScaffoldError(`the ${kind} template leaves ${left[0]} unfilled`);
184
+ return source;
185
+ }
186
+ function packageManifest(packageName) {
187
+ const manifest = {
188
+ name: packageName,
189
+ version: '0.1.0',
190
+ private: true,
191
+ type: 'module',
192
+ exports: './index.js',
193
+ peerDependencies: { 'manychat-ai-agent': '*' },
194
+ };
195
+ return `${JSON.stringify(manifest, null, 2)}\n`;
196
+ }
197
+ /**
198
+ * The project's files as they were, so a failure part-way puts them back
199
+ * (specs/041 § It makes all four edits, or none).
200
+ */
201
+ class Edits {
202
+ changed = [];
203
+ created;
204
+ write;
205
+ constructor(write) {
206
+ this.write = write;
207
+ }
208
+ mkdir(dir) {
209
+ this.created = mkdirSync(dir, { recursive: true });
210
+ }
211
+ file(path, content) {
212
+ // Recorded before the write, so a write that fails half-way is undone too.
213
+ this.changed.push({ path, before: textOf(path) });
214
+ this.write(path, content);
215
+ }
216
+ undo() {
217
+ for (const { path, before } of this.changed.reverse()) {
218
+ if (before === undefined)
219
+ rmSync(path, { force: true });
220
+ else
221
+ writeFileSync(path, before);
222
+ }
223
+ if (this.created)
224
+ rmSync(this.created, { recursive: true, force: true });
225
+ }
226
+ }
227
+ /**
228
+ * Writes `plugins/<name>/` and makes the three edits that load it, or refuses
229
+ * and leaves the project as it found it. Installs nothing. `write` is the
230
+ * file write, replaceable so a test can fail one part-way.
231
+ */
232
+ export async function scaffoldPlugin(options) {
233
+ const { root, kind } = options;
234
+ const names = pluginNames(options.name);
235
+ const { packageName, pluginName, toolName } = names;
236
+ const { manifest, text: manifestText } = projectManifest(root);
237
+ const directory = join('plugins', packageName);
238
+ if (existsSync(join(root, directory)))
239
+ throw new ScaffoldError(`${directory} already exists`);
240
+ const configDir = join(root, 'config');
241
+ let listed;
242
+ try {
243
+ listed = listedPlugins(configDir);
244
+ }
245
+ catch (error) {
246
+ if (error instanceof ConfigError)
247
+ throw new ScaffoldError(`config/${error.message}`);
248
+ throw error;
249
+ }
250
+ if (listed.includes(packageName)) {
251
+ throw new ScaffoldError(`config/${PLUGINS_FILE} already lists ${packageName}`);
252
+ }
253
+ const declared = await declaredNames(root, listed);
254
+ if (BUILT_IN_TOOLS.has(toolName)) {
255
+ throw new ScaffoldError(`${toolName} is a built-in tool's name; choose another <name>`);
256
+ }
257
+ const owner = declared.tools.get(toolName);
258
+ if (owner !== undefined) {
259
+ throw new ScaffoldError(`${owner} already declares the tool ${toolName}; choose another <name>`);
260
+ }
261
+ if (declared.plugins.has(pluginName)) {
262
+ throw new ScaffoldError(`a listed plugin is already named ${pluginName}; choose another <name>`);
263
+ }
264
+ // Everything is worked out before the first write, so a refusal writes nothing.
265
+ const source = renderTemplate(kind, names);
266
+ const workspacePath = join(root, WORKSPACE_FILE);
267
+ const workspace = withWorkspaceGlob(textOf(workspacePath));
268
+ const dependency = withDependency(manifest, manifestText, packageName);
269
+ const pluginsFile = `${JSON.stringify({ plugins: [...listed, packageName] }, null, 2)}\n`;
270
+ const edits = new Edits(options.write ?? ((path, content) => writeFileSync(path, content)));
271
+ const pluginDir = join(root, directory);
272
+ try {
273
+ edits.mkdir(pluginDir);
274
+ edits.file(join(pluginDir, 'package.json'), packageManifest(packageName));
275
+ edits.file(join(pluginDir, 'index.js'), source);
276
+ if (workspace !== undefined)
277
+ edits.file(workspacePath, workspace);
278
+ edits.file(join(root, 'package.json'), dependency);
279
+ edits.file(join(configDir, PLUGINS_FILE), pluginsFile);
280
+ await checkGenerated(packageName, join(pluginDir, 'index.js'), declared.tools);
281
+ }
282
+ catch (error) {
283
+ edits.undo();
284
+ throw error;
285
+ }
286
+ return { ...names, kind, directory };
287
+ }
288
+ /**
289
+ * Runs the loader's startup checks on the generated plugin, from its path:
290
+ * it is not in `node_modules` until `pnpm install` (specs/041 § It ends with
291
+ * the check the server will run). A failure is the agent's own bug.
292
+ */
293
+ async function checkGenerated(packageName, entry, taken) {
294
+ try {
295
+ const module = (await import(__rewriteRelativeImportExtension(pathToFileURL(entry).href)));
296
+ checkPlugin(packageName, module.default, new Map(taken));
297
+ }
298
+ catch (error) {
299
+ throw new ScaffoldError(`the generated plugin does not pass the agent's own checks, which is a bug in the agent: ` +
300
+ error.message);
301
+ }
302
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "manychat-ai-agent",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "A provider-agnostic conversational AI agent gateway that bridges no-code chat platforms to any LLM.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,6 +28,7 @@
28
28
  "files": [
29
29
  "dist/",
30
30
  "db/migrations/",
31
+ "src/plugins/templates/",
31
32
  "README.md",
32
33
  "LICENSE",
33
34
  "CHANGELOG.md"
@@ -0,0 +1,57 @@
1
+ // A read tool, generated by `agent plugin new` from the agent's own example
2
+ // (specs/041). Every name, value and address below is invented: replace them
3
+ // with your own. The model calls the tool while it writes the reply, and is
4
+ // shown only the result it declares, checked by the agent (specs/039).
5
+ import { definePlugin, defineReadTool } from 'manychat-ai-agent';
6
+
7
+ // Your backend goes here. This address does not exist, so until it is
8
+ // replaced every call fails, and the model is told the answer is unavailable.
9
+ const BACKEND_URL = 'https://backend.example.com';
10
+
11
+ export default definePlugin({
12
+ name: '__PLUGIN_NAME__',
13
+ apiVersion: __API_VERSION__,
14
+ tools: [
15
+ defineReadTool({
16
+ name: '__TOOL_NAME__',
17
+ // The only guidance the model gets: say when to call the tool, not what
18
+ // it does inside.
19
+ description:
20
+ 'Look up whether an offering has room and when its next slot is, when the contact asks whether they can have it or when.',
21
+ // One parameter of each type a read tool may take. Keep the ones your
22
+ // backend needs, and change their names and values to yours. The query
23
+ // is the one free text, and the agent removes identifiers from it.
24
+ parameters: {
25
+ offering: { type: 'enum', values: ['basic', 'premium'] },
26
+ quantity: { type: 'number', integer: true, min: 1, max: 12, optional: true },
27
+ flexible: { type: 'boolean', optional: true },
28
+ query: { type: 'query', maxLength: 120, optional: true },
29
+ },
30
+ // What the model is shown. A value outside these declarations makes the
31
+ // whole read unavailable, so declare exactly what you return.
32
+ result: {
33
+ slotsLeft: { type: 'number', integer: true, min: 0 },
34
+ nextSlot: { type: 'enum', values: ['this_week', 'next_week', 'later'] },
35
+ waitlist: { type: 'boolean' },
36
+ summary: { type: 'text', maxLength: 300, optional: true },
37
+ },
38
+ // Throw, or take too long, and the model is told the answer is
39
+ // unavailable. Pass `signal` to anything that waits.
40
+ async read({ params, signal }) {
41
+ // Your code goes here: the request your backend expects.
42
+ const url = new URL('/availability', BACKEND_URL);
43
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, String(value));
44
+ const response = await fetch(url, { signal });
45
+ if (!response.ok) throw new Error(`backend answered ${response.status}`);
46
+ const found = await response.json();
47
+ // Return only what `result` declares.
48
+ return {
49
+ slotsLeft: found.slotsLeft,
50
+ nextSlot: found.nextSlot,
51
+ waitlist: found.waitlist,
52
+ ...(found.summary ? { summary: found.summary } : {}),
53
+ };
54
+ },
55
+ }),
56
+ ],
57
+ });
@@ -0,0 +1,44 @@
1
+ // A write tool, generated by `agent plugin new` from the agent's own example
2
+ // (specs/041). Every name, value and address below is invented: replace them
3
+ // with your own. The model calls the tool while it writes the reply, and
4
+ // `perform` runs once the reply has been sent (specs/036).
5
+ import { definePlugin, defineTool } from 'manychat-ai-agent';
6
+
7
+ // Your backend goes here. This address does not exist, so until it is
8
+ // replaced every call fails, and the agent logs the failure and moves on.
9
+ const BACKEND_URL = 'https://backend.example.com';
10
+
11
+ export default definePlugin({
12
+ name: '__PLUGIN_NAME__',
13
+ apiVersion: __API_VERSION__,
14
+ tools: [
15
+ defineTool({
16
+ name: '__TOOL_NAME__',
17
+ // The only guidance the model gets: say when to call the tool, not what
18
+ // it does inside.
19
+ description:
20
+ 'Record a follow-up request once the contact has named an offering and asked to be contacted.',
21
+ // One parameter of each type a write tool may take. Keep the ones your
22
+ // backend needs, and change their names and values to yours.
23
+ parameters: {
24
+ offering: { type: 'enum', values: ['basic', 'premium'] },
25
+ quantity: { type: 'number', integer: true, min: 1, max: 4, optional: true },
26
+ urgent: { type: 'boolean' },
27
+ summary: { type: 'note', maxLength: 200, optional: true },
28
+ },
29
+ // Throw to report a failure: it is logged and recorded on the turn, and
30
+ // never retried. Pass `signal` to anything that waits.
31
+ async perform({ subscriberId, params, logger, signal }) {
32
+ // Your code goes here: the request your backend expects.
33
+ const response = await fetch(`${BACKEND_URL}/follow-ups`, {
34
+ method: 'POST',
35
+ headers: { 'content-type': 'application/json' },
36
+ body: JSON.stringify({ subscriberId, ...params }),
37
+ signal,
38
+ });
39
+ if (!response.ok) throw new Error(`backend answered ${response.status}`);
40
+ logger.info('follow-up recorded', { offering: params.offering });
41
+ },
42
+ }),
43
+ ],
44
+ });