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,132 @@
|
|
|
1
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { parseJson } from './util.ts';
|
|
4
|
+
|
|
5
|
+
// Node builtins only: scripts/backup.ts imports config.ts, which imports this.
|
|
6
|
+
|
|
7
|
+
/** This machine's tailnet IPv4 address and MagicDNS name, or nulls when Tailscale is not up. */
|
|
8
|
+
export interface TailscaleSelf {
|
|
9
|
+
ip: string | null;
|
|
10
|
+
dnsName: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type TailscaleProbe = () => Promise<TailscaleSelf>;
|
|
14
|
+
|
|
15
|
+
const NONE: TailscaleSelf = { ip: null, dnsName: null };
|
|
16
|
+
const ARGS = ['status', '--self', '--peers=false', '--json'];
|
|
17
|
+
|
|
18
|
+
export function parseTailscaleStatus(json: string): TailscaleSelf {
|
|
19
|
+
try {
|
|
20
|
+
const status = JSON.parse(json);
|
|
21
|
+
if (status.BackendState !== undefined && status.BackendState !== 'Running') return NONE;
|
|
22
|
+
const self = status.Self ?? {};
|
|
23
|
+
const ip = (self.TailscaleIPs ?? []).find((a: unknown) => typeof a === 'string' && a.includes('.')) ?? null;
|
|
24
|
+
const dnsName = typeof self.DNSName === 'string' && self.DNSName ? self.DNSName.replace(/\.$/, '').toLowerCase() : null;
|
|
25
|
+
return ip ? { ip, dnsName } : NONE;
|
|
26
|
+
} catch {
|
|
27
|
+
return NONE;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Used once at startup. */
|
|
32
|
+
export function tailscaleSelfSync(): TailscaleSelf {
|
|
33
|
+
try {
|
|
34
|
+
return parseTailscaleStatus(execFileSync('tailscale', ARGS, { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }));
|
|
35
|
+
} catch {
|
|
36
|
+
return NONE;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const tailscaleSelf: TailscaleProbe = () =>
|
|
41
|
+
new Promise((resolve) => {
|
|
42
|
+
execFile('tailscale', ARGS, { encoding: 'utf8', timeout: 5000 }, (err, stdout) => resolve(err ? NONE : parseTailscaleStatus(stdout)));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** From `tailscale whois --json <ip>`, for one-click owner pairing. */
|
|
46
|
+
export interface TailnetPeer {
|
|
47
|
+
login: string;
|
|
48
|
+
/** Short machine name, e.g. "my-phone". */
|
|
49
|
+
node: string;
|
|
50
|
+
nodeId: string;
|
|
51
|
+
/** Tagged nodes act for the tailnet, not a person, so they never count as the owner. */
|
|
52
|
+
tagged: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface TailnetSelf {
|
|
56
|
+
/** Null for a tagged machine. */
|
|
57
|
+
login: string | null;
|
|
58
|
+
nodeId: string;
|
|
59
|
+
ips: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Tailnet {
|
|
63
|
+
whois(ip: string): Promise<TailnetPeer | null>;
|
|
64
|
+
self(): Promise<TailnetSelf | null>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Runs `tailscale <args>`: its stdout, or null if it fails. */
|
|
68
|
+
export type RunCommand = (args: string[]) => Promise<string | null>;
|
|
69
|
+
|
|
70
|
+
const run: RunCommand = (args) =>
|
|
71
|
+
new Promise((resolve) => {
|
|
72
|
+
execFile('tailscale', args, { encoding: 'utf8', timeout: 3000, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => resolve(err ? null : stdout));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** Strips the IPv4-mapped prefix of dual-stack sockets ("::ffff:100.1.2.3"). */
|
|
76
|
+
export function plainAddress(ip: string): string {
|
|
77
|
+
return ip.startsWith('::ffff:') && isIP(ip.slice(7)) === 4 ? ip.slice(7) : ip;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 100.64.0.0/10 and fd7a:115c:a1e0::/48. */
|
|
81
|
+
export function isTailnetAddress(raw: string): boolean {
|
|
82
|
+
const ip = plainAddress(raw);
|
|
83
|
+
if (isIP(ip) === 4) {
|
|
84
|
+
const [a, b] = ip.split('.').map(Number);
|
|
85
|
+
return a === 100 && b >= 64 && b <= 127;
|
|
86
|
+
}
|
|
87
|
+
return isIP(ip) === 6 && /^fd7a:115c:a1e0:/i.test(ip);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isLoopback(raw: string): boolean {
|
|
91
|
+
const ip = plainAddress(raw);
|
|
92
|
+
return ip === '::1' || (isIP(ip) === 4 && ip.startsWith('127.'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Cached for 30 s, so a page load doesn't spawn a process per request. */
|
|
96
|
+
export function tailscaleCli(exec: RunCommand = run): Tailnet {
|
|
97
|
+
const cache = new Map<string, { at: number; value: Promise<any> }>();
|
|
98
|
+
const cached = <T>(key: string, load: () => Promise<T>): Promise<T> => {
|
|
99
|
+
const hit = cache.get(key);
|
|
100
|
+
if (hit && Date.now() - hit.at < 30_000) return hit.value;
|
|
101
|
+
const value = load();
|
|
102
|
+
cache.set(key, { at: Date.now(), value });
|
|
103
|
+
return value;
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
whois(raw) {
|
|
107
|
+
const ip = plainAddress(raw);
|
|
108
|
+
if (!isTailnetAddress(ip)) return Promise.resolve(null);
|
|
109
|
+
return cached(`whois ${ip}`, async () => {
|
|
110
|
+
const data = parseJson<any>(await exec(['whois', '--json', ip]), null);
|
|
111
|
+
const login = data?.UserProfile?.LoginName;
|
|
112
|
+
const node = data?.Node;
|
|
113
|
+
if (typeof login !== 'string' || !login || !node) return null;
|
|
114
|
+
const name = String(node.ComputedName || node.Hostinfo?.Hostname || String(node.Name ?? '').split('.')[0] || ip);
|
|
115
|
+
const tags = Array.isArray(node.Tags) ? node.Tags : [];
|
|
116
|
+
return { login, node: name, nodeId: String(node.StableID ?? node.ID ?? ''), tagged: tags.length > 0 || login === 'tagged-devices' };
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
self() {
|
|
120
|
+
return cached('self', async () => {
|
|
121
|
+
const data = parseJson<any>(await exec(['status', '--self', '--json']), null);
|
|
122
|
+
const self = data?.Self;
|
|
123
|
+
if (!self) return null;
|
|
124
|
+
const user = data.User?.[String(self.UserID)];
|
|
125
|
+
const tagged = Array.isArray(self.Tags) && self.Tags.length > 0;
|
|
126
|
+
const login = !tagged && typeof user?.LoginName === 'string' && user.LoginName !== 'tagged-devices' ? user.LoginName : null;
|
|
127
|
+
const ips = Array.isArray(self.TailscaleIPs) ? self.TailscaleIPs.map(String) : [];
|
|
128
|
+
return { login, nodeId: String(self.ID ?? ''), ips };
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { RENAMED } from '../../shared/legacy-names.mjs';
|
|
3
|
+
import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { Bot, BotTemplate, NewBotInput, Routine, SaveTemplateInput, TemplateBot, TemplateRoutine } from '../../shared/types.ts';
|
|
6
|
+
import { effortProblem } from '../../shared/effort.ts';
|
|
7
|
+
import { isPastOneOff, isValidTimezone, MIN_TIME_LIMIT_MS } from '../../shared/util.ts';
|
|
8
|
+
import { log } from './log.ts';
|
|
9
|
+
import { describeSchedule, parseSchedule, toSpec } from './schedule.ts';
|
|
10
|
+
import { SKILL_NAME, type Skills } from './skills.ts';
|
|
11
|
+
import { STARTERS, type StoredStarter } from './starters.ts';
|
|
12
|
+
import { type BotRecord, clean, flag, HttpError, MAX_ROUTINES_PER_BOT, NOTIFY_POLICIES, type Store, storedLook } from './store.ts';
|
|
13
|
+
import { newId, nowIso, orNull } from './util.ts';
|
|
14
|
+
import { isWebhookSource, parseFilter } from './webhooks.ts';
|
|
15
|
+
|
|
16
|
+
// A saved template (BOTONS_HOME/templates/<id>.json) never holds chat, memories, sessions, webhook keys, signing secrets or callback URLs.
|
|
17
|
+
|
|
18
|
+
export type StoredTemplate = StoredStarter;
|
|
19
|
+
|
|
20
|
+
const SAVED_ID = /^tpl_[a-z0-9]{8}$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Templates an import committed but whose files may not be in place yet: a settings row ([{ id, sha }]) written in
|
|
24
|
+
* the import's own transaction. Its files wait beside the others as <id>.json.staged (which list() never reads)
|
|
25
|
+
* until publish() checks each against its sha and renames it into place. So a template is shown exactly when its
|
|
26
|
+
* import committed: a crash before the commit leaves only staged files (removed at the next start), one after it a
|
|
27
|
+
* row that the next start, or the next read, publishes.
|
|
28
|
+
*/
|
|
29
|
+
const OUTBOX = 'templateOutbox';
|
|
30
|
+
const STAGED = '.staged';
|
|
31
|
+
const SHA = /^[0-9a-f]{64}$/;
|
|
32
|
+
type Pending = { id: string; sha: string };
|
|
33
|
+
|
|
34
|
+
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
|
|
35
|
+
|
|
36
|
+
/** Read only a regular file, through the descriptor that was opened without following its final link. */
|
|
37
|
+
function readPlain(path: string): string {
|
|
38
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
39
|
+
try {
|
|
40
|
+
if (!fstatSync(fd).isFile()) throw new Error('not a plain file');
|
|
41
|
+
return readFileSync(fd, 'utf8');
|
|
42
|
+
} finally {
|
|
43
|
+
closeSync(fd);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Its data on disk before anything says it is there (a power cut, not only a crash). */
|
|
48
|
+
function syncPath(path: string, dir = false): void {
|
|
49
|
+
const fd = openSync(path, dir ? 'r' : 'r+');
|
|
50
|
+
try {
|
|
51
|
+
fsyncSync(fd);
|
|
52
|
+
} finally {
|
|
53
|
+
closeSync(fd);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function scheduleText(r: Pick<TemplateRoutine, 'schedule' | 'timezone'>, tz: string): string | null {
|
|
58
|
+
if (!r.schedule) return null;
|
|
59
|
+
try {
|
|
60
|
+
const zone = r.timezone ?? tz;
|
|
61
|
+
return describeSchedule(toSpec(parseSchedule(r.schedule, zone)), zone);
|
|
62
|
+
} catch {
|
|
63
|
+
return r.schedule;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const str = (v: unknown, max: number): string | null => (typeof v === 'string' && v.length <= max ? v : null);
|
|
68
|
+
|
|
69
|
+
const strings = (v: unknown): string[] | null => (Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : null);
|
|
70
|
+
|
|
71
|
+
export function readRoutine(v: any): Omit<TemplateRoutine, 'scheduleText'> | null {
|
|
72
|
+
if (!v || typeof v !== 'object') return null;
|
|
73
|
+
const name = str(v.name, 80);
|
|
74
|
+
const instruction = str(v.instruction, 20_000);
|
|
75
|
+
if (!name?.trim() || !instruction?.trim()) return null;
|
|
76
|
+
return {
|
|
77
|
+
name,
|
|
78
|
+
instruction,
|
|
79
|
+
schedule: typeof v.schedule === 'string' && v.schedule.trim() ? v.schedule : null,
|
|
80
|
+
timezone: typeof v.timezone === 'string' && isValidTimezone(v.timezone) ? v.timezone : null,
|
|
81
|
+
webhook: v.webhook === true,
|
|
82
|
+
notify: NOTIFY_POLICIES.includes(v.notify) ? v.notify : 'auto',
|
|
83
|
+
webhookWorkspace: v.webhookWorkspace !== false,
|
|
84
|
+
webhookSource: v.webhook === true && isWebhookSource(v.webhookSource) ? v.webhookSource : 'generic',
|
|
85
|
+
webhookFilter: v.webhook === true ? orNull(() => parseFilter(v.webhookFilter)) : null,
|
|
86
|
+
skill: typeof v.skill === 'string' && SKILL_NAME.test(v.skill) ? v.skill : null,
|
|
87
|
+
model: str(v.model, 120)?.trim() || null,
|
|
88
|
+
effort: str(v.effort, 24)?.trim() || null,
|
|
89
|
+
session: v.session === 'own' ? 'own' : 'chat',
|
|
90
|
+
timeLimitMs: Number.isInteger(v.timeLimitMs) && v.timeLimitMs >= MIN_TIME_LIMIT_MS ? v.timeLimitMs : null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readTemplate(id: string, v: any): StoredTemplate | null {
|
|
95
|
+
if (!v || typeof v !== 'object' || !v.bot || typeof v.bot !== 'object') return null;
|
|
96
|
+
const b = v.bot;
|
|
97
|
+
const name = str(v.name, 60);
|
|
98
|
+
if (!name?.trim()) return null;
|
|
99
|
+
const routines = Array.isArray(v.routines) ? v.routines.map(readRoutine) : [];
|
|
100
|
+
if (routines.some((r: unknown) => r === null)) return null;
|
|
101
|
+
return {
|
|
102
|
+
id,
|
|
103
|
+
name,
|
|
104
|
+
description: str(v.description, 300) ?? '',
|
|
105
|
+
builtin: false,
|
|
106
|
+
createdAt: str(v.createdAt, 40),
|
|
107
|
+
bot: {
|
|
108
|
+
name: str(b.name, 60) ?? '',
|
|
109
|
+
title: str(b.title, 80) ?? '',
|
|
110
|
+
instructions: str(b.instructions, 20_000) ?? '',
|
|
111
|
+
// A file saved with an earlier look (a Critter or a capsule) gets its closest Boton; anything else, none.
|
|
112
|
+
look: storedLook(b.look, `template:${id}`),
|
|
113
|
+
provider: str(b.provider, 80),
|
|
114
|
+
model: str(b.model, 120) ?? '',
|
|
115
|
+
effort: str(b.effort, 24)?.trim() ?? '',
|
|
116
|
+
access: b.access === 'host' ? 'host' : 'workspace',
|
|
117
|
+
group: str(b.group, 40) ?? '',
|
|
118
|
+
timezone: typeof b.timezone === 'string' && isValidTimezone(b.timezone) ? b.timezone : null,
|
|
119
|
+
// A template saved under the browser connector's deprecated id reads with its current one (shared/legacy-names.mjs).
|
|
120
|
+
connections: (strings(b.connections) ?? []).map((c) => (c === `lib:${RENAMED.botons_browser}` ? 'lib:botons_browser' : c)),
|
|
121
|
+
skills: b.skills === null || b.skills === undefined ? null : (strings(b.skills) ?? null),
|
|
122
|
+
},
|
|
123
|
+
routines,
|
|
124
|
+
// Only a built-in starter declares needs: a saved file can't make itself one.
|
|
125
|
+
needs: null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class Templates {
|
|
130
|
+
#dir: string;
|
|
131
|
+
#store: Store;
|
|
132
|
+
#skills: Skills;
|
|
133
|
+
/** The server's limit for a turn: a template's routine made here gets no longer one than that. */
|
|
134
|
+
#maxTimeLimitMs: number;
|
|
135
|
+
|
|
136
|
+
constructor(dir: string, store: Store, skills: Skills, maxTimeLimitMs: number) {
|
|
137
|
+
this.#dir = dir;
|
|
138
|
+
this.#store = store;
|
|
139
|
+
this.#skills = skills;
|
|
140
|
+
this.#maxTimeLimitMs = maxTimeLimitMs;
|
|
141
|
+
this.publish();
|
|
142
|
+
this.#sweep();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#pending(): Pending[] {
|
|
146
|
+
const raw = (this.#store.db.prepare('select value from settings where key = ?').get(OUTBOX) as { value: string } | undefined)?.value;
|
|
147
|
+
if (raw === undefined) return [];
|
|
148
|
+
try {
|
|
149
|
+
const list = JSON.parse(raw);
|
|
150
|
+
if (Array.isArray(list)) return list.filter((x): x is Pending => !!x && SAVED_ID.test(x.id) && SHA.test(x.sha));
|
|
151
|
+
} catch {}
|
|
152
|
+
log.error('the list of imported templates waiting to be put in place is unreadable: it is dropped');
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#setPending(list: Pending[]): void {
|
|
157
|
+
if (list.length) this.#store.db.prepare('insert into settings(key, value) values (?, ?) on conflict(key) do update set value = excluded.value').run(OUTBOX, JSON.stringify(list));
|
|
158
|
+
else this.#store.db.prepare('delete from settings where key = ?').run(OUTBOX);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Puts in place every template an import committed (see OUTBOX): each staged file that is a plain file with the
|
|
163
|
+
* sha its import recorded is renamed to <id>.json; one that is missing, not a plain file or different is never
|
|
164
|
+
* shown, and is named in the log. One that fails (a folder it can't write) stays listed and is tried again at the
|
|
165
|
+
* next read or start. Idempotent: one already in place is done.
|
|
166
|
+
*/
|
|
167
|
+
publish(): void {
|
|
168
|
+
const pending = this.#pending();
|
|
169
|
+
if (!pending.length) return;
|
|
170
|
+
const left: Pending[] = [];
|
|
171
|
+
let placed = 0;
|
|
172
|
+
for (const p of pending) {
|
|
173
|
+
const final = join(this.#dir, `${p.id}.json`);
|
|
174
|
+
const staged = `${final}${STAGED}`;
|
|
175
|
+
try {
|
|
176
|
+
const here = lstatSync(final, { throwIfNoEntry: false });
|
|
177
|
+
if (here) {
|
|
178
|
+
if (!here.isFile() || sha256(readPlain(final)) !== p.sha) throw new Error('the existing final file is not the reviewed artifact; keeping its recovery record and staged file');
|
|
179
|
+
syncPath(this.#dir, true);
|
|
180
|
+
rmSync(staged, { force: true });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const st = lstatSync(staged, { throwIfNoEntry: false });
|
|
184
|
+
if (!st?.isFile() || sha256(readPlain(staged)) !== p.sha) throw new Error('the reviewed staged artifact is missing, linked or changed; keeping its recovery record');
|
|
185
|
+
renameSync(staged, final);
|
|
186
|
+
syncPath(this.#dir, true);
|
|
187
|
+
placed++;
|
|
188
|
+
} catch (err) {
|
|
189
|
+
log.warn(`imported template ${p.id} is not in place yet (${(err as NodeJS.ErrnoException).code ?? (err as Error).message}); trying again at the next read`);
|
|
190
|
+
left.push(p);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (placed) log.info('templates imported', { templates: placed });
|
|
194
|
+
this.#setPending(left);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** At start: staged files no committed import lists (it crashed before its commit), and save()'s half-written ones. */
|
|
198
|
+
#sweep(): void {
|
|
199
|
+
if (!existsSync(this.#dir)) return;
|
|
200
|
+
const listed = new Set(this.#pending().map((p) => `${p.id}.json${STAGED}`));
|
|
201
|
+
for (const f of readdirSync(this.#dir)) {
|
|
202
|
+
const id = f.replace(/\.json(?:\.tmp|\.staged)$/, '');
|
|
203
|
+
if (f === id || !SAVED_ID.test(id) || listed.has(f)) continue;
|
|
204
|
+
try {
|
|
205
|
+
rmSync(join(this.#dir, f), { force: true });
|
|
206
|
+
log.info(`removed ${join(this.#dir, f)}: no committed import or save wrote it whole`);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
log.warn(`cannot remove ${join(this.#dir, f)}: ${(err as Error).message}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
list(): BotTemplate[] {
|
|
214
|
+
const tz = this.#store.getSettings().timezone;
|
|
215
|
+
const withText = (t: StoredTemplate): BotTemplate => ({
|
|
216
|
+
...t,
|
|
217
|
+
routines: t.routines.map((r) => ({ ...r, scheduleText: scheduleText(r, t.bot.timezone ?? tz) })),
|
|
218
|
+
});
|
|
219
|
+
const saved = this.#saved().sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
|
220
|
+
return [...STARTERS, ...saved].map(withText);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
get(id: string): BotTemplate {
|
|
224
|
+
const t = this.find(id);
|
|
225
|
+
if (!t) throw new HttpError(404, `No template with id ${id}`);
|
|
226
|
+
return t;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
find(id: unknown): BotTemplate | null {
|
|
230
|
+
return this.list().find((x) => x.id === id) ?? null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#saved(): StoredTemplate[] {
|
|
234
|
+
// What committed imports hold is shown, so put it in place first (nothing to do almost always).
|
|
235
|
+
this.publish();
|
|
236
|
+
if (!existsSync(this.#dir)) return [];
|
|
237
|
+
const blocked = new Set(this.#pending().map((p) => p.id));
|
|
238
|
+
const out = [];
|
|
239
|
+
for (const file of readdirSync(this.#dir)) {
|
|
240
|
+
const id = file.replace(/\.json$/, '');
|
|
241
|
+
if (!file.endsWith('.json') || !SAVED_ID.test(id) || blocked.has(id)) continue;
|
|
242
|
+
try {
|
|
243
|
+
const t = readTemplate(id, JSON.parse(readPlain(join(this.#dir, file))));
|
|
244
|
+
if (t) out.push(t);
|
|
245
|
+
else log.warn(`skipping ${join(this.#dir, file)}: it is not a Botons template`);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
log.warn(`skipping ${join(this.#dir, file)}: ${(err as Error).message}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
save(input: SaveTemplateInput): BotTemplate {
|
|
254
|
+
if (typeof input.fromBotId !== 'string') throw new HttpError(400, 'fromBotId is required');
|
|
255
|
+
const bot = this.#store.getBot(input.fromBotId);
|
|
256
|
+
const name = clean(input.name, 'name', 60) || bot.name;
|
|
257
|
+
const description = clean(input.description, 'description', 300) || (bot.title ? `${bot.title}, saved from ${bot.name}.` : `Saved from ${bot.name}.`);
|
|
258
|
+
const routines = this.#store.listRoutines(bot.id).filter((r) => !(r.schedule?.startsWith('once ')));
|
|
259
|
+
const id = newId('tpl');
|
|
260
|
+
const file = {
|
|
261
|
+
version: 1,
|
|
262
|
+
name,
|
|
263
|
+
description,
|
|
264
|
+
createdAt: nowIso(),
|
|
265
|
+
bot: templateBot(bot),
|
|
266
|
+
routines: routines.map((r) => templateRoutine(r, bot)),
|
|
267
|
+
};
|
|
268
|
+
mkdirSync(this.#dir, { recursive: true, mode: 0o700 });
|
|
269
|
+
const path = join(this.#dir, `${id}.json`);
|
|
270
|
+
const tmp = `${path}.tmp`;
|
|
271
|
+
writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
272
|
+
chmodSync(tmp, 0o600);
|
|
273
|
+
renameSync(tmp, path);
|
|
274
|
+
log.info('template saved', { botId: bot.id, templateId: id, routines: routines.length });
|
|
275
|
+
return this.get(id);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The owner's saved templates as stored (never the built-in ones), by name: for the configuration file. */
|
|
279
|
+
savedTemplates(): StoredTemplate[] {
|
|
280
|
+
return this.#saved().sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Saved templates from a configuration file (portable.ts), each under a new id, as OUTBOX describes: stage() writes
|
|
285
|
+
* their files aside (synced, never over an existing one) before the import's transaction; record(), inside it,
|
|
286
|
+
* lists them in the outbox and publishes them once it commits; discard() removes the files of a transaction that
|
|
287
|
+
* didn't commit (any its outbox doesn't list).
|
|
288
|
+
*/
|
|
289
|
+
stage(entries: Pick<StoredTemplate, 'name' | 'description' | 'bot' | 'routines'>[]): { ids: string[]; record(): void; discard(): void } {
|
|
290
|
+
const files = entries.map((t) => {
|
|
291
|
+
const body = `${JSON.stringify({ version: 1, name: t.name, description: t.description, createdAt: nowIso(), bot: t.bot, routines: t.routines }, null, 2)}\n`;
|
|
292
|
+
return { id: newId('tpl'), body, sha: sha256(body) };
|
|
293
|
+
});
|
|
294
|
+
const staged = (id: string) => join(this.#dir, `${id}.json${STAGED}`);
|
|
295
|
+
const discard = () => {
|
|
296
|
+
const committed = new Set(this.#pending().map((p) => p.id));
|
|
297
|
+
for (const f of files) if (!committed.has(f.id)) rmSync(staged(f.id), { force: true });
|
|
298
|
+
};
|
|
299
|
+
try {
|
|
300
|
+
if (files.length) mkdirSync(this.#dir, { recursive: true, mode: 0o700 });
|
|
301
|
+
for (const f of files) {
|
|
302
|
+
// 'wx': a new file, never through a link or over one that is there.
|
|
303
|
+
writeFileSync(staged(f.id), f.body, { mode: 0o600, flag: 'wx' });
|
|
304
|
+
chmodSync(staged(f.id), 0o600);
|
|
305
|
+
syncPath(staged(f.id));
|
|
306
|
+
}
|
|
307
|
+
if (files.length) syncPath(this.#dir, true);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
discard();
|
|
310
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
311
|
+
throw new HttpError(500, `Couldn't write the imported templates (${code ?? (err as Error).message}): nothing was imported.`);
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
ids: files.map((f) => f.id),
|
|
315
|
+
record: () => {
|
|
316
|
+
if (!files.length) return;
|
|
317
|
+
if (!this.#store.db.isTransaction) throw new Error('templates are recorded inside the import\'s transaction');
|
|
318
|
+
this.#setPending([...this.#pending(), ...files.map((f) => ({ id: f.id, sha: f.sha }))]);
|
|
319
|
+
this.#store.afterCommit(() => this.publish());
|
|
320
|
+
},
|
|
321
|
+
discard,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
delete(id: string): void {
|
|
326
|
+
if (STARTERS.some((t) => t.id === id)) throw new HttpError(400, "Built-in templates can't be deleted");
|
|
327
|
+
if (!SAVED_ID.test(id) || !existsSync(join(this.#dir, `${id}.json`))) throw new HttpError(404, `No template with id ${id}`);
|
|
328
|
+
rmSync(join(this.#dir, `${id}.json`));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** `input` is what the New Boton form holds (the template only filled it in); the template adds its routines. */
|
|
332
|
+
createBot(input: NewBotInput, providers: Set<string>, defaultProvider: string): Bot {
|
|
333
|
+
const template = this.#templateFor(input.templateId);
|
|
334
|
+
const start = flag(input.startRoutines, 'startRoutines', false);
|
|
335
|
+
// A starter's routines are turned on by the owner after its setup check, never by installing it.
|
|
336
|
+
if (template.needs && start) throw new HttpError(400, `${template.name}'s routines start paused: turn them on after its setup check`);
|
|
337
|
+
const { templateId: _t, startRoutines: _s, reviewKey: _k, starterConnectors: slots, ...fields } = input;
|
|
338
|
+
const tz = fields.timezone ?? this.#store.getSettings().timezone;
|
|
339
|
+
if (template.routines.length > MAX_ROUTINES_PER_BOT) throw new HttpError(400, `A Boton can own at most ${MAX_ROUTINES_PER_BOT} routines`);
|
|
340
|
+
for (const r of template.routines) {
|
|
341
|
+
const zone = r.timezone ?? tz;
|
|
342
|
+
if (!isValidTimezone(zone)) throw new HttpError(400, `Template routine “${r.name}” has an unknown timezone ${zone}`);
|
|
343
|
+
if (r.schedule) {
|
|
344
|
+
try {
|
|
345
|
+
parseSchedule(r.schedule, zone);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
throw new HttpError(400, `Template routine “${r.name}”: ${(err as Error).message}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// All or nothing, so a failed routine leaves no half-made Boton for a retry to make again.
|
|
352
|
+
const bot = this.#store.atomically(() => {
|
|
353
|
+
const made = this.#store.createBot(fields, providers, defaultProvider);
|
|
354
|
+
const sameProvider = made.provider === template.bot.provider;
|
|
355
|
+
const routineIds: string[] = [];
|
|
356
|
+
for (const r of template.routines) {
|
|
357
|
+
const routine = this.#store.createRoutine(
|
|
358
|
+
made.id,
|
|
359
|
+
{
|
|
360
|
+
name: r.name,
|
|
361
|
+
instruction: r.instruction,
|
|
362
|
+
schedule: r.schedule,
|
|
363
|
+
timezone: r.timezone ?? made.timezone,
|
|
364
|
+
webhook: r.webhook,
|
|
365
|
+
enabled: start,
|
|
366
|
+
notify: r.notify,
|
|
367
|
+
webhookWorkspace: r.webhookWorkspace,
|
|
368
|
+
...(r.webhook ? { webhookSource: r.webhookSource, webhookFilter: r.webhookFilter } : {}),
|
|
369
|
+
// A skill the new Boton may not use (left off its list, deleted, or another Boton's pending one) is left out.
|
|
370
|
+
skill: r.skill && this.#skills.enabledFor(made, r.skill) ? r.skill : null,
|
|
371
|
+
model: sameProvider ? r.model : null,
|
|
372
|
+
// Levels are named alike across providers; one the new Boton's provider or the run's model doesn't have is left out.
|
|
373
|
+
effort: r.effort && !effortProblem(made.provider, r.effort, this.#store.effortCaps(made.provider, (sameProvider && r.model) || made.model)) ? r.effort : null,
|
|
374
|
+
session: r.session,
|
|
375
|
+
// Saved on a server that lets turns run longer: as long as this one does.
|
|
376
|
+
timeLimitMs: r.timeLimitMs === null ? null : Math.min(r.timeLimitMs, this.#maxTimeLimitMs),
|
|
377
|
+
},
|
|
378
|
+
{ chip: false },
|
|
379
|
+
);
|
|
380
|
+
routineIds.push(routine.id);
|
|
381
|
+
}
|
|
382
|
+
// A starter's routines wait on the owner's decision (starter-gate.ts), in the same transaction they are made in.
|
|
383
|
+
if (template.needs) {
|
|
384
|
+
const chosen = Object.fromEntries((template.needs.connectors ?? []).map((n) => [n.slot, slots?.[n.slot] ?? null]));
|
|
385
|
+
this.#store.saveStarterRecord(made.id, {
|
|
386
|
+
templateId: template.id,
|
|
387
|
+
templateName: template.name,
|
|
388
|
+
version: template.needs.version,
|
|
389
|
+
slots: chosen,
|
|
390
|
+
installedAt: nowIso(),
|
|
391
|
+
pending: routineIds,
|
|
392
|
+
tests: [],
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const count = template.routines.length;
|
|
396
|
+
const what = count ? `; ${count === 1 ? '1 routine' : `${count} routines`}${start ? '' : ', paused'}` : '';
|
|
397
|
+
this.#store.addMessage(made.id, 'event', `Started from template “${template.name}”${what}`, {
|
|
398
|
+
kind: 'bot.from_template',
|
|
399
|
+
templateId: template.id,
|
|
400
|
+
sourceName: template.name,
|
|
401
|
+
copiedRoutines: count,
|
|
402
|
+
});
|
|
403
|
+
return this.#store.publicBot(this.#store.getBot(made.id));
|
|
404
|
+
});
|
|
405
|
+
log.info('Boton created from a template', { botId: bot.id, templateId: template.id, routines: template.routines.length });
|
|
406
|
+
return bot;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#templateFor(id: unknown): BotTemplate {
|
|
410
|
+
if (typeof id !== 'string' || !id) throw new HttpError(400, 'templateId must be a template id');
|
|
411
|
+
try {
|
|
412
|
+
return this.get(id);
|
|
413
|
+
} catch {
|
|
414
|
+
throw new HttpError(400, `No template with id ${id}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function templateBot(bot: BotRecord): TemplateBot {
|
|
420
|
+
return {
|
|
421
|
+
name: bot.name,
|
|
422
|
+
title: bot.title,
|
|
423
|
+
instructions: bot.instructions,
|
|
424
|
+
look: bot.look,
|
|
425
|
+
provider: bot.provider,
|
|
426
|
+
model: bot.model,
|
|
427
|
+
effort: bot.effort,
|
|
428
|
+
access: bot.access,
|
|
429
|
+
group: bot.group,
|
|
430
|
+
timezone: bot.timezone,
|
|
431
|
+
connections: bot.connections,
|
|
432
|
+
skills: bot.skills,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function templateRoutine(r: Routine, bot: Pick<Bot, 'timezone'>): Omit<TemplateRoutine, 'scheduleText'> {
|
|
437
|
+
return {
|
|
438
|
+
name: r.name,
|
|
439
|
+
instruction: r.instruction,
|
|
440
|
+
schedule: r.schedule && !isPastOneOff(r.schedule) ? r.schedule : null,
|
|
441
|
+
// The Boton's own timezone becomes the new Boton's.
|
|
442
|
+
timezone: r.timezone === bot.timezone ? null : r.timezone,
|
|
443
|
+
webhook: !!r.webhook,
|
|
444
|
+
notify: r.notify,
|
|
445
|
+
webhookWorkspace: r.webhookWorkspace,
|
|
446
|
+
webhookSource: r.webhook?.source ?? 'generic',
|
|
447
|
+
webhookFilter: r.webhook?.filter ?? null,
|
|
448
|
+
skill: r.skill,
|
|
449
|
+
model: r.model,
|
|
450
|
+
effort: r.effort,
|
|
451
|
+
session: r.session,
|
|
452
|
+
timeLimitMs: r.timeLimitMs,
|
|
453
|
+
};
|
|
454
|
+
}
|