getbotons 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import type {
|
|
4
|
+
Bot,
|
|
5
|
+
CreateSkillInput,
|
|
6
|
+
InstallSkillInput,
|
|
7
|
+
Skill,
|
|
8
|
+
SkillAccess,
|
|
9
|
+
SkillAccessInput,
|
|
10
|
+
SkillAuthor,
|
|
11
|
+
SkillHistoryEntry,
|
|
12
|
+
SkillPackage,
|
|
13
|
+
SkillPackageReview,
|
|
14
|
+
SkillPackagesList,
|
|
15
|
+
SkillSource,
|
|
16
|
+
UpdateSkillInput,
|
|
17
|
+
} from '../../shared/types.ts';
|
|
18
|
+
import type { Approvals } from './approvals.ts';
|
|
19
|
+
import type { EventHub } from './events.ts';
|
|
20
|
+
import { log } from './log.ts';
|
|
21
|
+
import type { SkillIndexEntry } from './prompt.ts';
|
|
22
|
+
import { asksOf, lineDiff, listPackages, packageDetails, type PackageRead, readPackage } from './skill-packages.ts';
|
|
23
|
+
import { HttpError, type Store } from './store.ts';
|
|
24
|
+
import { nowIso, parseJson, type Row, truncate } from './util.ts';
|
|
25
|
+
|
|
26
|
+
// Skills live in the database and never in the system prompt, so changing them keeps every provider session.
|
|
27
|
+
// A skill a Boton saved or changed last is pending: only that Boton uses it until the owner approves (or
|
|
28
|
+
// edits) it, so a Boton that read something crafted can't hand every other Boton a procedure.
|
|
29
|
+
|
|
30
|
+
/** A SKILL.md name: lowercase letters and digits in words joined by single hyphens. */
|
|
31
|
+
export const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
32
|
+
const NAME_MAX = 64;
|
|
33
|
+
const DESCRIPTION_MAX = 1024;
|
|
34
|
+
const BODY_MAX = 50_000;
|
|
35
|
+
const INDEX_DESCRIPTION_MAX = 200;
|
|
36
|
+
/** Replaced texts kept per skill, newest first. */
|
|
37
|
+
export const SKILL_HISTORY_KEEP = 10;
|
|
38
|
+
|
|
39
|
+
/** What the owner reads before approving or installing: the description and the body. */
|
|
40
|
+
export const skillVersion = (description: string, body: string) => createHash('sha256').update(description).update('\0').update(body).digest('hex').slice(0, 32);
|
|
41
|
+
|
|
42
|
+
/** Everything the owner reviews of a package: its text (as skillVersion) and its details (packageDetails). */
|
|
43
|
+
export const packageFingerprint = (description: string, body: string, details: string) =>
|
|
44
|
+
createHash('sha256').update('package\0').update(skillVersion(description, body)).update('\0').update(details).digest('hex').slice(0, 32);
|
|
45
|
+
|
|
46
|
+
/** The details a skill was installed with, as packageDetails writes a package's; empty for one written here. */
|
|
47
|
+
const sourceDetails = (source: SkillSource | null) => (source ? packageDetails({ label: source.label, fields: source.fields, otherFiles: source.files ?? [] }) : '');
|
|
48
|
+
|
|
49
|
+
const OWNER: SkillAuthor = { kind: 'owner' };
|
|
50
|
+
|
|
51
|
+
/** A Boton's change to who has a skill, for the audit log. */
|
|
52
|
+
export interface SkillGrantChange {
|
|
53
|
+
botId: string;
|
|
54
|
+
name: string;
|
|
55
|
+
added: boolean;
|
|
56
|
+
/** It had every skill, and now lists every other one. */
|
|
57
|
+
wasEvery: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ids = (v: unknown, field: string): string[] => {
|
|
61
|
+
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) throw new HttpError(400, `${field} must be a list of Boton ids`);
|
|
62
|
+
return [...new Set(v as string[])];
|
|
63
|
+
};
|
|
64
|
+
const sameSet = (a: string[], b: string[]) => a.length === b.length && a.every((x) => b.includes(x));
|
|
65
|
+
const has = (bot: Pick<Bot, 'skills'>, name: string) => bot.skills === null || bot.skills.includes(name);
|
|
66
|
+
|
|
67
|
+
function text(v: unknown, field: string, max: number): string {
|
|
68
|
+
if (typeof v !== 'string' || !v.trim()) throw new HttpError(400, `${field} is required`);
|
|
69
|
+
const t = v.trim();
|
|
70
|
+
if (t.length > max) throw new HttpError(400, `${field} is too long (max ${max} characters)`);
|
|
71
|
+
return t;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** "Juniper (a Boton)" for a skill a Boton wrote. */
|
|
75
|
+
const byline = (a: SkillAuthor) => (a.kind === 'boton' ? `${a.name} (a Boton)` : null);
|
|
76
|
+
|
|
77
|
+
export class Skills {
|
|
78
|
+
#db: DatabaseSync;
|
|
79
|
+
#store: Store;
|
|
80
|
+
#packagesDir: string;
|
|
81
|
+
approvals!: Approvals;
|
|
82
|
+
|
|
83
|
+
constructor(db: DatabaseSync, opts: { hub: EventHub; store: Store; packagesDir: string }) {
|
|
84
|
+
this.#db = db;
|
|
85
|
+
this.#store = opts.store;
|
|
86
|
+
this.#packagesDir = opts.packagesDir;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#toSkill(r: Row): Skill {
|
|
90
|
+
const updatedBy: SkillAuthor = JSON.parse(r.updated_by);
|
|
91
|
+
return {
|
|
92
|
+
name: r.name,
|
|
93
|
+
description: r.description,
|
|
94
|
+
body: r.body,
|
|
95
|
+
createdBy: JSON.parse(r.created_by),
|
|
96
|
+
updatedBy,
|
|
97
|
+
createdAt: r.created_at,
|
|
98
|
+
updatedAt: r.updated_at,
|
|
99
|
+
version: skillVersion(r.description, r.body),
|
|
100
|
+
pending: updatedBy.kind === 'boton',
|
|
101
|
+
disabled: !!r.disabled,
|
|
102
|
+
source: parseJson<SkillSource | null>(r.source, null),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
list(): Skill[] {
|
|
107
|
+
return this.#db
|
|
108
|
+
.prepare('select * from skills order by name')
|
|
109
|
+
.all()
|
|
110
|
+
.map((r) => this.#toSkill(r as Row));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
find(name: string): Skill | null {
|
|
114
|
+
const r = this.#db.prepare('select * from skills where name = ?').get(name) as Row | undefined;
|
|
115
|
+
return r ? this.#toSkill(r) : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get(name: string): Skill {
|
|
119
|
+
const s = this.find(name);
|
|
120
|
+
if (!s) throw new HttpError(404, `No skill named ${name}`);
|
|
121
|
+
return s;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#emit(name: string): Skill {
|
|
125
|
+
const skill = this.get(name);
|
|
126
|
+
this.#store.publish({ type: 'skill.upserted', skill });
|
|
127
|
+
return skill;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Keeps the skill's text as it is now, before it is replaced, and drops the oldest past SKILL_HISTORY_KEEP. */
|
|
131
|
+
#remember(name: string, by: SkillAuthor): void {
|
|
132
|
+
const r = this.#db.prepare('select * from skills where name = ?').get(name) as Row;
|
|
133
|
+
this.#db
|
|
134
|
+
.prepare('insert into skill_history (name, version, description, body, source, replaced_at, replaced_by) values (?, ?, ?, ?, ?, ?, ?)')
|
|
135
|
+
.run(name, skillVersion(r.description, r.body), r.description, r.body, r.source ?? null, nowIso(), JSON.stringify(by));
|
|
136
|
+
this.#db.prepare('delete from skill_history where name = ? and id not in (select id from skill_history where name = ? order by id desc limit ?)').run(name, name, SKILL_HISTORY_KEEP);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The texts it had before, newest first. */
|
|
140
|
+
history(name: string): SkillHistoryEntry[] {
|
|
141
|
+
this.get(name);
|
|
142
|
+
return (this.#db.prepare('select * from skill_history where name = ? order by id desc').all(name) as Row[]).map((r) => ({
|
|
143
|
+
id: Number(r.id),
|
|
144
|
+
version: r.version,
|
|
145
|
+
description: r.description,
|
|
146
|
+
body: r.body,
|
|
147
|
+
source: parseJson<SkillSource | null>(r.source, null),
|
|
148
|
+
replacedAt: r.replaced_at,
|
|
149
|
+
replacedBy: JSON.parse(r.replaced_by),
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The owner puts back an earlier text (and the source it came from), from the version they read: 409 if the skill
|
|
155
|
+
* changed since. The text it replaces is kept too, so a restore can be undone the same way.
|
|
156
|
+
*/
|
|
157
|
+
restore(name: string, input: { id?: unknown; current?: unknown }): { skill: Skill; from: string; to: string } {
|
|
158
|
+
const out = this.#store.atomically(() => {
|
|
159
|
+
const skill = this.get(name);
|
|
160
|
+
if (typeof input?.current !== 'string' || skill.version !== input.current) throw new HttpError(409, `The skill ${name} has changed since you read it: look again.`);
|
|
161
|
+
const entry = this.history(name).find((h) => h.id === input.id);
|
|
162
|
+
if (!entry) throw new HttpError(404, `The skill ${name} has no earlier text with id ${String(input?.id)}`);
|
|
163
|
+
this.#remember(name, OWNER);
|
|
164
|
+
this.#db
|
|
165
|
+
.prepare('update skills set description = ?, body = ?, source = ?, updated_by = ?, updated_at = ? where name = ?')
|
|
166
|
+
.run(entry.description, entry.body, entry.source ? JSON.stringify(entry.source) : null, JSON.stringify(OWNER), nowIso(), name);
|
|
167
|
+
return { from: skill.version, to: entry.version };
|
|
168
|
+
});
|
|
169
|
+
log.info('skill restored', { skill: name, from: out.from, to: out.to });
|
|
170
|
+
this.approvals.resolveSkill(name, 'allowed');
|
|
171
|
+
return { skill: this.#emit(name), ...out };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Off: no Boton sees it, /name is plain text, and routines that follow it run without it. */
|
|
175
|
+
setEnabled(name: string, enabled: unknown): Skill {
|
|
176
|
+
if (typeof enabled !== 'boolean') throw new HttpError(400, 'enabled must be true or false');
|
|
177
|
+
const skill = this.get(name);
|
|
178
|
+
if (skill.disabled === !enabled) return skill;
|
|
179
|
+
this.#db.prepare('update skills set disabled = ? where name = ?').run(enabled ? 0 : 1, name);
|
|
180
|
+
if (enabled) log.info('skill turned on', { skill: name });
|
|
181
|
+
else log.info('skill turned off', { skill: name });
|
|
182
|
+
return this.#emit(name);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Every Boton, and whether it has the skill (in its list, or every skill). Pending and off skills too: having one is still a grant. */
|
|
186
|
+
access(name: string): SkillAccess {
|
|
187
|
+
return {
|
|
188
|
+
name,
|
|
189
|
+
botons: this.#store
|
|
190
|
+
.listBots()
|
|
191
|
+
.map((b) => ({ botId: b.id, name: b.name, has: has(b, name), every: b.skills === null }))
|
|
192
|
+
.sort((a, b) => Number(b.has) - Number(a.has) || a.name.localeCompare(b.name)),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Gives the skill to exactly `botIds` and takes it off every other Boton, all at once or not at all; refused (409)
|
|
198
|
+
* if who had it is not `expected`. A Boton with every skill that loses it lists every other skill in the library
|
|
199
|
+
* instead, so it keeps the rest but no longer gets new ones without being given them.
|
|
200
|
+
*/
|
|
201
|
+
setAccess(name: string, input: SkillAccessInput): SkillGrantChange[] {
|
|
202
|
+
const wanted = ids(input?.botIds, 'botIds');
|
|
203
|
+
const expected = ids(input?.expected, 'expected');
|
|
204
|
+
return this.#store.atomically(() => {
|
|
205
|
+
this.get(name);
|
|
206
|
+
const bots = this.#store.listBots();
|
|
207
|
+
const unknown = wanted.find((id) => !bots.some((b) => b.id === id));
|
|
208
|
+
if (unknown) throw new HttpError(400, `No Boton with id ${unknown}`);
|
|
209
|
+
if (!sameSet(bots.filter((b) => has(b, name)).map((b) => b.id), expected)) throw new HttpError(409, `Which Botons have ${name} changed since you looked: review it again`);
|
|
210
|
+
const library = this.list().map((s) => s.name);
|
|
211
|
+
const out: SkillGrantChange[] = [];
|
|
212
|
+
for (const b of bots) {
|
|
213
|
+
const add = wanted.includes(b.id) && !has(b, name);
|
|
214
|
+
const remove = !wanted.includes(b.id) && has(b, name);
|
|
215
|
+
if (add) this.#store.setSkills(b.id, [...(b.skills ?? []), name]);
|
|
216
|
+
else if (remove) this.#store.setSkills(b.id, b.skills === null ? library.filter((n) => n !== name) : b.skills.filter((n) => n !== name));
|
|
217
|
+
if (add || remove) out.push({ botId: b.id, name: b.name, added: add, wasEvery: remove && b.skills === null });
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
get packagesDir(): string {
|
|
224
|
+
return this.#packagesDir;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Current only while both the skill's text and everything reviewed of the package are as they were installed. */
|
|
228
|
+
#state(p: PackageRead, version: string | null, fingerprint: string | null, installed: Skill | null): SkillPackage['state'] {
|
|
229
|
+
if (p.problems.length || !version || !fingerprint) return 'invalid';
|
|
230
|
+
if (!installed) return 'new';
|
|
231
|
+
if (!installed.source || installed.source.folder !== p.folder) return 'conflict';
|
|
232
|
+
if (installed.source.reviewed !== fingerprint) return 'update';
|
|
233
|
+
return installed.version === version ? 'current' : 'changed';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#package(p: PackageRead): SkillPackage {
|
|
237
|
+
const version = p.description && p.body ? skillVersion(p.description, p.body) : null;
|
|
238
|
+
const fingerprint = version ? packageFingerprint(p.description!, p.body!, JSON.stringify([p.metadataHash, packageDetails(p)])) : null;
|
|
239
|
+
const installed = p.name === p.folder ? this.find(p.folder) : null;
|
|
240
|
+
const state = this.#state(p, version, fingerprint, installed);
|
|
241
|
+
const asks = version ? asksOf({ description: p.description!, body: p.body!, fields: p.fields }) : [];
|
|
242
|
+
const had = installed?.source ? asksOf({ description: installed.description, body: installed.body, fields: installed.source.fields }) : [];
|
|
243
|
+
return {
|
|
244
|
+
folder: p.folder,
|
|
245
|
+
state,
|
|
246
|
+
problems: state === 'conflict' ? [`A skill named ${p.folder} was not installed from this package. Rename or delete it to install this one.`] : p.problems,
|
|
247
|
+
name: p.name,
|
|
248
|
+
description: p.description,
|
|
249
|
+
body: p.body,
|
|
250
|
+
label: p.label,
|
|
251
|
+
version,
|
|
252
|
+
fingerprint,
|
|
253
|
+
fields: p.fields,
|
|
254
|
+
otherFiles: p.otherFiles,
|
|
255
|
+
asks,
|
|
256
|
+
newAsks: asks.filter((a) => !had.includes(a)),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** What is in the skill packages folder. Reads files only: nothing in it is run. */
|
|
261
|
+
packages(): SkillPackagesList {
|
|
262
|
+
const read = listPackages(this.#packagesDir, (n) => n.length <= NAME_MAX && SKILL_NAME.test(n));
|
|
263
|
+
return { dir: this.#packagesDir, exists: read !== null, packages: (read ?? []).map((p) => this.#package(p)) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#read(folder: string): SkillPackage {
|
|
267
|
+
const p = readPackage(this.#packagesDir, folder, (n) => n.length <= NAME_MAX && SKILL_NAME.test(n));
|
|
268
|
+
if (!p) throw new HttpError(404, `No skill package named ${folder} in ${this.#packagesDir}`);
|
|
269
|
+
return this.#package(p);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** One package and what installing it would change. */
|
|
273
|
+
review(folder: string): SkillPackageReview {
|
|
274
|
+
const pkg = this.#read(folder);
|
|
275
|
+
const installed = pkg.state === 'invalid' ? null : this.find(folder);
|
|
276
|
+
const shown = pkg.state === 'conflict' ? null : installed;
|
|
277
|
+
return {
|
|
278
|
+
...pkg,
|
|
279
|
+
installed,
|
|
280
|
+
diff: {
|
|
281
|
+
description: lineDiff(shown?.description ?? '', pkg.description ?? ''),
|
|
282
|
+
body: lineDiff(shown?.body ?? '', pkg.body ?? ''),
|
|
283
|
+
details: lineDiff(sourceDetails(shown?.source ?? null), packageDetails(pkg)),
|
|
284
|
+
},
|
|
285
|
+
// For a new skill, who would have it once it is in the library: the Botons with every skill.
|
|
286
|
+
botons: installed ? this.access(folder).botons : this.access(folder).botons.map((m) => ({ ...m, has: m.every })),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Installs or updates a skill from its package, exactly as the owner reviewed it, and gives it to exactly the
|
|
292
|
+
* Botons they chose, all at once or not at all. Its text is read again here and must be the version reviewed; the
|
|
293
|
+
* skill must be the one reviewed; what it asks for that the installed one didn't must be what the owner accepted.
|
|
294
|
+
* The text replaced is kept in its history.
|
|
295
|
+
*/
|
|
296
|
+
install(folder: string, input: InstallSkillInput): { skill: Skill; from: string | null; label: string | null; accepted: string[]; grants: SkillGrantChange[] } {
|
|
297
|
+
if (typeof input?.version !== 'string' || !input.version) throw new HttpError(400, 'version is required: the version of the package you reviewed');
|
|
298
|
+
if (typeof input.fingerprint !== 'string' || !input.fingerprint) throw new HttpError(400, 'fingerprint is required: the fingerprint of the package you reviewed');
|
|
299
|
+
if (input.current !== null && typeof input.current !== 'string') throw new HttpError(400, 'current must be the version of the installed skill you reviewed, or null for a new one');
|
|
300
|
+
if (!Array.isArray(input.accept) || !input.accept.every((a) => typeof a === 'string')) throw new HttpError(400, "accept must list what the package asks for that you accepted (its newAsks)");
|
|
301
|
+
const out = this.#store.atomically(() => {
|
|
302
|
+
const pkg = this.#read(folder);
|
|
303
|
+
if (pkg.state === 'invalid') throw new HttpError(400, `The package ${folder} can't be installed: ${pkg.problems.join(' ')}`);
|
|
304
|
+
if (pkg.state === 'conflict') throw new HttpError(409, pkg.problems[0]);
|
|
305
|
+
if (pkg.version !== input.version || pkg.fingerprint !== input.fingerprint) throw new HttpError(409, `The package ${folder} has changed since you reviewed it: review it again.`);
|
|
306
|
+
const installed = this.find(folder);
|
|
307
|
+
if ((installed?.version ?? null) !== input.current) throw new HttpError(409, `The skill ${folder} has changed since you reviewed it: review it again.`);
|
|
308
|
+
if (!sameSet(pkg.newAsks, [...new Set(input.accept as string[])])) throw new HttpError(409, `What ${folder} asks for has changed since you reviewed it: review it again.`);
|
|
309
|
+
const source: SkillSource = { kind: 'folder', folder, label: pkg.label, installed: pkg.version, installedAt: nowIso(), fields: pkg.fields, files: pkg.otherFiles, reviewed: pkg.fingerprint! };
|
|
310
|
+
const now = nowIso();
|
|
311
|
+
if (installed) {
|
|
312
|
+
this.#remember(folder, OWNER);
|
|
313
|
+
this.#db
|
|
314
|
+
.prepare('update skills set description = ?, body = ?, source = ?, updated_by = ?, updated_at = ? where name = ?')
|
|
315
|
+
.run(pkg.description, pkg.body, JSON.stringify(source), JSON.stringify(OWNER), now, folder);
|
|
316
|
+
} else {
|
|
317
|
+
this.#db
|
|
318
|
+
.prepare('insert into skills (name, description, body, created_by, updated_by, created_at, updated_at, source) values (?, ?, ?, ?, ?, ?, ?, ?)')
|
|
319
|
+
.run(folder, pkg.description, pkg.body, JSON.stringify(OWNER), JSON.stringify(OWNER), now, now, JSON.stringify(source));
|
|
320
|
+
}
|
|
321
|
+
const grants = this.setAccess(folder, { botIds: input.botIds, expected: input.expected });
|
|
322
|
+
return { from: installed?.version ?? null, label: pkg.label, accepted: pkg.newAsks, grants };
|
|
323
|
+
});
|
|
324
|
+
log.info('skill installed', { skill: folder, from: out.from });
|
|
325
|
+
return { skill: this.#emit(folder), ...out };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
create(input: CreateSkillInput, by: SkillAuthor = { kind: 'owner' }): Skill {
|
|
329
|
+
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
|
330
|
+
if (!name || name.length > NAME_MAX || !SKILL_NAME.test(name)) {
|
|
331
|
+
throw new HttpError(400, `name must be lowercase letters and digits, words joined by single hyphens (like weekly-report), at most ${NAME_MAX}`);
|
|
332
|
+
}
|
|
333
|
+
if (this.find(name)) throw new HttpError(409, `There is already a skill named ${name}`);
|
|
334
|
+
const description = text(input.description, 'description', DESCRIPTION_MAX);
|
|
335
|
+
const body = text(input.body, 'body', BODY_MAX);
|
|
336
|
+
const now = nowIso();
|
|
337
|
+
const who = JSON.stringify(by);
|
|
338
|
+
this.#db
|
|
339
|
+
.prepare('insert into skills (name, description, body, created_by, updated_by, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?)')
|
|
340
|
+
.run(name, description, body, who, who, now, now);
|
|
341
|
+
log.info('skill saved', { skill: name, by: by.kind === 'boton' ? by.botId : 'owner' });
|
|
342
|
+
return this.#emit(name);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
update(name: string, input: UpdateSkillInput, by: SkillAuthor = { kind: 'owner' }): Skill {
|
|
346
|
+
const current = this.get(name);
|
|
347
|
+
const rename = (input as { name?: unknown }).name;
|
|
348
|
+
if (rename !== undefined && rename !== name) {
|
|
349
|
+
throw new HttpError(400, "A skill's name can't change; save it under the new name and delete the old one");
|
|
350
|
+
}
|
|
351
|
+
const sets: string[] = [];
|
|
352
|
+
const vals: string[] = [];
|
|
353
|
+
if (input.description !== undefined) {
|
|
354
|
+
sets.push('description = ?');
|
|
355
|
+
vals.push(text(input.description, 'description', DESCRIPTION_MAX));
|
|
356
|
+
}
|
|
357
|
+
if (input.body !== undefined) {
|
|
358
|
+
sets.push('body = ?');
|
|
359
|
+
vals.push(text(input.body, 'body', BODY_MAX));
|
|
360
|
+
}
|
|
361
|
+
if (!sets.length) return this.get(name);
|
|
362
|
+
const next = { description: input.description !== undefined ? vals[0] : current.description, body: input.body !== undefined ? vals.at(-1)! : current.body };
|
|
363
|
+
if (skillVersion(next.description, next.body) !== current.version) this.#remember(name, by);
|
|
364
|
+
this.#db.prepare(`update skills set ${sets.join(', ')}, updated_by = ?, updated_at = ? where name = ?`).run(...vals, JSON.stringify(by), nowIso(), name);
|
|
365
|
+
log.info('skill changed', { skill: name, by: by.kind === 'boton' ? by.botId : 'owner' });
|
|
366
|
+
// The owner's edit approves it.
|
|
367
|
+
if (by.kind === 'owner') this.approvals.resolveSkill(name, 'allowed');
|
|
368
|
+
return this.#emit(name);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The owner approves a pending skill at the `version` they read (409 if it changed since). */
|
|
372
|
+
approve(name: string, version: unknown): Skill {
|
|
373
|
+
if (typeof version !== 'string' || !version) throw new HttpError(400, 'version is required: the version of the skill you read (Skill.version)');
|
|
374
|
+
const skill = this.get(name);
|
|
375
|
+
if (skill.version !== version) {
|
|
376
|
+
throw new HttpError(409, `The skill ${name} has changed since you read it${skill.pending ? '. Read it again before approving it' : ''}.`);
|
|
377
|
+
}
|
|
378
|
+
if (!skill.pending) return skill;
|
|
379
|
+
this.#db.prepare('update skills set updated_by = ?, updated_at = ? where name = ?').run(JSON.stringify({ kind: 'owner' } satisfies SkillAuthor), nowIso(), name);
|
|
380
|
+
log.info('skill approved', { skill: name, by: skill.updatedBy.kind === 'boton' ? skill.updatedBy.botId : 'owner' });
|
|
381
|
+
this.approvals.resolveSkill(name, 'allowed');
|
|
382
|
+
return this.#emit(name);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Refused while a routine follows it; Botons that had it in their list lose it. */
|
|
386
|
+
delete(name: string): void {
|
|
387
|
+
this.get(name);
|
|
388
|
+
const users = this.#db
|
|
389
|
+
.prepare('select r.name as routine, b.name as bot from routines r join bots b on b.id = r.bot_id where r.skill = ? order by b.name, r.name')
|
|
390
|
+
.all(name) as Row[];
|
|
391
|
+
if (users.length) {
|
|
392
|
+
const list = users.map((u) => `“${u.routine}” (${u.bot})`).join(', ');
|
|
393
|
+
throw new HttpError(409, `Routine${users.length > 1 ? 's' : ''} ${list} follow${users.length > 1 ? '' : 's'} this skill. Change ${users.length > 1 ? 'them' : 'it'} first.`);
|
|
394
|
+
}
|
|
395
|
+
this.#db.prepare('delete from skills where name = ?').run(name);
|
|
396
|
+
this.#db.prepare('delete from skill_history where name = ?').run(name);
|
|
397
|
+
this.#store.dropFromBots('skills', name);
|
|
398
|
+
this.#store.publish({ type: 'skill.deleted', name });
|
|
399
|
+
this.approvals.resolveSkill(name, 'cancelled');
|
|
400
|
+
log.info('skill deleted', { skill: name });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A skill from a configuration file (portable.ts), inside the caller's transaction: made, or with `replace` put in
|
|
405
|
+
* place of the one here (whose text goes to its history first). Its text, author, source and earlier texts are
|
|
406
|
+
* kept as given; what it grants is nothing, as for any skill. A Boton's skill stays pending until approved.
|
|
407
|
+
*/
|
|
408
|
+
importSkill(
|
|
409
|
+
entry: { name: string; description: string; body: string; disabled: boolean; author: SkillAuthor; source: SkillSource | null; history: { description: string; body: string; source: SkillSource | null; replacedAt: string }[] },
|
|
410
|
+
replace: boolean,
|
|
411
|
+
): Skill {
|
|
412
|
+
const now = nowIso();
|
|
413
|
+
const by = JSON.stringify(entry.author);
|
|
414
|
+
const source = entry.source ? JSON.stringify(entry.source) : null;
|
|
415
|
+
if (replace) {
|
|
416
|
+
this.get(entry.name);
|
|
417
|
+
this.#remember(entry.name, OWNER);
|
|
418
|
+
this.#db
|
|
419
|
+
.prepare('update skills set description = ?, body = ?, source = ?, disabled = ?, updated_by = ?, updated_at = ? where name = ?')
|
|
420
|
+
.run(entry.description, entry.body, source, entry.disabled ? 1 : 0, by, now, entry.name);
|
|
421
|
+
} else {
|
|
422
|
+
if (this.find(entry.name)) throw new HttpError(409, `There is already a skill named ${entry.name}`);
|
|
423
|
+
this.#db
|
|
424
|
+
.prepare('insert into skills (name, description, body, created_by, updated_by, created_at, updated_at, source, disabled) values (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
425
|
+
.run(entry.name, entry.description, entry.body, by, by, now, now, source, entry.disabled ? 1 : 0);
|
|
426
|
+
// Oldest first, so the newest gets the highest id, as history() reads them.
|
|
427
|
+
const add = this.#db.prepare('insert into skill_history (name, version, description, body, source, replaced_at, replaced_by) values (?, ?, ?, ?, ?, ?, ?)');
|
|
428
|
+
for (const h of [...entry.history].slice(0, SKILL_HISTORY_KEEP).reverse()) {
|
|
429
|
+
add.run(entry.name, skillVersion(h.description, h.body), h.description, h.body, h.source ? JSON.stringify(h.source) : null, h.replacedAt, JSON.stringify(OWNER));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
log.info('skill imported', { skill: entry.name });
|
|
433
|
+
// A card waiting on the text it replaced no longer describes it: the owner's import approves it, a Boton's withdraws it.
|
|
434
|
+
if (replace) this.approvals.resolveSkill(entry.name, entry.author.kind === 'owner' ? 'allowed' : 'cancelled');
|
|
435
|
+
return this.#emit(entry.name);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** In the Boton's list (null: every skill), and not pending unless the Boton wrote it. */
|
|
439
|
+
#usable(bot: Pick<Bot, 'id' | 'skills'>, skill: Skill): boolean {
|
|
440
|
+
if (skill.disabled) return false;
|
|
441
|
+
if (bot.skills !== null && !bot.skills.includes(skill.name)) return false;
|
|
442
|
+
return !skill.pending || (skill.updatedBy.kind === 'boton' && skill.updatedBy.botId === bot.id);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
forBot(bot: Pick<Bot, 'id' | 'skills'>): Skill[] {
|
|
446
|
+
return this.list().filter((s) => this.#usable(bot, s));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
enabledFor(bot: Pick<Bot, 'id' | 'skills'>, name: string): Skill | null {
|
|
450
|
+
const skill = this.find(name);
|
|
451
|
+
return skill && this.#usable(bot, skill) ? skill : null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
getFor(botId: string, name: string): Skill {
|
|
455
|
+
const skill = this.enabledFor(this.#store.getBot(botId), name);
|
|
456
|
+
if (!skill) throw new HttpError(404, `No skill named ${name} is enabled for this Boton`);
|
|
457
|
+
return skill;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** `botons skill create`: the Boton's own (added to its list, if it has one), with a chip and an approval card. */
|
|
461
|
+
createByBoton(botId: string, input: CreateSkillInput): Skill {
|
|
462
|
+
const bot = this.#store.getBot(botId);
|
|
463
|
+
const skill = this.create(input, { kind: 'boton', botId, name: bot.name });
|
|
464
|
+
if (bot.skills !== null) {
|
|
465
|
+
this.#db.prepare('update bots set skills = ? where id = ?').run(JSON.stringify([...bot.skills, skill.name]), botId);
|
|
466
|
+
this.#store.emitBot(botId);
|
|
467
|
+
}
|
|
468
|
+
this.#store.addMessage(botId, 'event', 'Saved a skill', { kind: 'skill.created', skillName: skill.name });
|
|
469
|
+
this.approvals.forSkill(botId, skill);
|
|
470
|
+
return skill;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** `botons skill update`: only a skill this Boton wrote, until anyone else changed it. */
|
|
474
|
+
updateByBoton(botId: string, name: string, input: UpdateSkillInput): Skill {
|
|
475
|
+
const bot = this.#store.getBot(botId);
|
|
476
|
+
const skill = this.get(name);
|
|
477
|
+
const mine = (a: SkillAuthor) => a.kind === 'boton' && a.botId === botId;
|
|
478
|
+
if (!mine(skill.createdBy) || !mine(skill.updatedBy)) {
|
|
479
|
+
const owner = this.#store.getSettings().ownerName;
|
|
480
|
+
throw new HttpError(403, `Only ${owner} can change the skill ${name}: a Boton can only change the skills it wrote, until ${owner} edits or approves them. Save yours under a new name instead.`);
|
|
481
|
+
}
|
|
482
|
+
const updated = this.update(name, input, { kind: 'boton', botId, name: bot.name });
|
|
483
|
+
this.#store.addMessage(botId, 'event', 'Updated a skill', { kind: 'skill.updated', skillName: name });
|
|
484
|
+
this.approvals.forSkill(botId, updated);
|
|
485
|
+
return updated;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** A Boton's list of skills as given: null (every skill), or names in the library (it may keep ones that are gone). */
|
|
489
|
+
checkAllowlist(value: unknown, current: string[] | null): string[] | null {
|
|
490
|
+
if (value === null) return null;
|
|
491
|
+
if (!Array.isArray(value) || !value.every((n) => typeof n === 'string')) throw new HttpError(400, 'skills must be a list of skill names, or null for every skill');
|
|
492
|
+
const list = [...new Set(value as string[])];
|
|
493
|
+
const unknown = list.find((n) => !this.find(n) && !(current ?? []).includes(n));
|
|
494
|
+
if (unknown) throw new HttpError(400, `No skill named ${unknown}`);
|
|
495
|
+
return list;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** A routine's skill as given: null, or one its Boton may use. */
|
|
499
|
+
checkRoutineSkill(botId: string, value: unknown): string | null {
|
|
500
|
+
if (value === undefined || value === null || value === '') return null;
|
|
501
|
+
if (typeof value !== 'string') throw new HttpError(400, 'skill must be a skill name or null');
|
|
502
|
+
const skill = this.find(value);
|
|
503
|
+
if (!skill) throw new HttpError(400, `No skill named ${value}`);
|
|
504
|
+
if (!this.#usable(this.#store.getBot(botId), skill)) {
|
|
505
|
+
throw new HttpError(400, skill.disabled ? `The skill ${value} is turned off` : skill.pending ? `The skill ${value} was written by another Boton and is waiting for approval` : `The skill ${value} is not enabled for this Boton`);
|
|
506
|
+
}
|
|
507
|
+
return value;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** The context block's index of the skills the Boton may use. */
|
|
511
|
+
index(bot: Pick<Bot, 'id' | 'skills'>): SkillIndexEntry[] {
|
|
512
|
+
return this.forBot(bot).map((s) => ({
|
|
513
|
+
name: s.name,
|
|
514
|
+
description: truncate(s.description, INDEX_DESCRIPTION_MAX),
|
|
515
|
+
by: byline(s.updatedBy) ?? byline(s.createdBy),
|
|
516
|
+
pending: s.pending,
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The skill a chat message invokes with a leading /<name>, if the Boton may use it. */
|
|
521
|
+
invoked(bot: Pick<Bot, 'id' | 'skills'>, message: string): { skill: Skill; input: string } | null {
|
|
522
|
+
const m = /^\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+([\s\S]*))?$/.exec(message.trim());
|
|
523
|
+
if (!m) return null;
|
|
524
|
+
const skill = this.enabledFor(bot, m[1]);
|
|
525
|
+
return skill ? { skill, input: (m[2] ?? '').trim() } : null;
|
|
526
|
+
}
|
|
527
|
+
}
|