tinywebui 0.1.4 → 0.1.5

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.
Files changed (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -53
  3. package/package.json +25 -8
  4. package/public/app.js +40 -34
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -53
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +84 -21
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -136
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -454
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +90 -72
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -111
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +426 -415
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
@@ -1,198 +1,185 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { FEATURE } from '../../public/shared/features.js';
3
-
4
- /**
5
- * Access policy: who can use which parts of TinyWebUI, declared in files.
6
- *
7
- * The `access` block lives in tinywebui.config.js and/or config.json:
8
- *
9
- * access: {
10
- * bootstrapAdmins: ['<gateway user id>'], // always admin, always approved
11
- * newUsers: 'approved' | 'pending',
12
- * roles: { user: { features: [...], models: [...] | '*' }, admin: { features: '*' } },
13
- * users: { '<gateway user id>': { role?, status? } } // admin decisions, written back
14
- * }
15
- *
16
- * Everything here is pure: no I/O, so the server, the reload path and the CLI
17
- * all validate and resolve the same way.
18
- */
19
-
20
- /** Every feature a role can be granted. Each maps to UI and API routes. */
21
3
  export const FEATURES = Object.values(FEATURE);
22
-
23
4
  /** What a signed-in user may personalise (access.customize). Model choice is the model-picker feature's. */
24
5
  export const CUSTOMIZABLE = ['theme', 'instructions'];
25
-
26
6
  export const ROLES = ['admin', 'user'];
27
7
  export const STATUSES = ['pending', 'approved', 'disabled'];
28
-
29
8
  // Tier 3's out-of-the-box split: users work, admins run the place.
30
9
  const DEFAULT_ROLES = {
31
- user: { features: [FEATURE.CHAT, FEATURE.ATTACHMENTS, FEATURE.IMAGES, FEATURE.SEARCH, FEATURE.FOLDERS, FEATURE.AUTOMATIONS, FEATURE.STATISTICS, FEATURE.MODEL_PICKER], models: '*' },
32
- admin: { features: '*', models: '*' }
10
+ user: { features: [FEATURE.CHAT, FEATURE.ATTACHMENTS, FEATURE.IMAGES, FEATURE.SEARCH, FEATURE.FOLDERS, FEATURE.AUTOMATIONS, FEATURE.STATISTICS, FEATURE.MODEL_PICKER], models: '*' },
11
+ admin: { features: '*', models: '*' }
33
12
  };
34
-
35
13
  /**
36
14
  * Keys that can never be written through the UI or API, whatever the lock
37
15
  * state: the trust boundary, credentials, and the policy itself. They can
38
- * still be set in either file (so `set-password` can write config.json).
16
+ * still be set in either file.
39
17
  */
40
18
  export const FILE_ONLY = new Set([
41
- 'authMode', 'authPassword', 'sessionSecret', 'sessionTtlDays', 'trustedProxyCidrs', 'trustedUserIdHeader',
42
- 'trustedEmailHeader', 'trustedNameHeader', 'trustedRoleHeader',
43
- 'logoutUrl', 'dbPath', 'access', 'allowedOrigins', 'allowedHosts', 'frozen', 'autoMigrate', 'retrieval',
44
- 'googleClientId', 'googleClientSecret', 'googleRedirectUri', 'adminEmails'
19
+ 'authMode', 'authPassword', 'sessionSecret', 'sessionTtlDays', 'trustedProxyCidrs', 'trustedUserIdHeader',
20
+ 'trustedEmailHeader', 'trustedNameHeader', 'trustedRoleHeader',
21
+ 'logoutUrl', 'dbPath', 'access', 'allowedOrigins', 'allowedHosts', 'frozen', 'autoMigrate', 'retrieval',
22
+ 'googleClientId', 'googleClientSecret', 'googleRedirectUri', 'adminEmails'
45
23
  ]);
46
-
47
24
  /**
48
25
  * Writable, but where credentials and routing live: in a multi-user
49
26
  * deployment only an admin may send these, whatever features a role has.
50
27
  */
51
28
  export const SECRET_PATCH_KEYS = new Set(['baseUrl', 'apiKey', 'models', 'connectors']);
52
-
53
29
  // Never logged, never fingerprinted as values, never sent to a browser.
54
30
  export const SECRET_KEYS = new Set(['apiKey', 'authPassword', 'sessionSecret', 'googleClientSecret']);
55
-
56
31
  /**
57
32
  * Merges the file's and the code's `access` blocks. Code wins per field;
58
33
  * `users` merges per user, code winning, so admin write-back to the file can
59
34
  * never override a user pinned in code.
60
35
  */
61
36
  export function mergeAccess(fileAccess = {}, codeAccess = {}) {
62
- const f = fileAccess && typeof fileAccess === 'object' ? fileAccess : {};
63
- const c = codeAccess && typeof codeAccess === 'object' ? codeAccess : {};
64
- return {
65
- ...f, ...c,
66
- users: { ...(f.users || {}), ...(c.users || {}) }
67
- };
37
+ const f = fileAccess && typeof fileAccess === 'object' ? fileAccess : {};
38
+ const c = codeAccess && typeof codeAccess === 'object' ? codeAccess : {};
39
+ return {
40
+ ...f, ...c,
41
+ users: { ...(f.users || {}), ...(c.users || {}) }
42
+ };
68
43
  }
69
-
70
44
  /** The effective policy, with defaults filled in, for a loaded config. */
71
45
  export function resolveAccess(cfg) {
72
- const a = cfg.access || {};
73
- const roles = {};
74
- for (const r of ROLES) roles[r] = { ...DEFAULT_ROLES[r], ...(a.roles?.[r] || {}) };
75
- return {
76
- bootstrapAdmins: Array.isArray(a.bootstrapAdmins) ? a.bootstrapAdmins.map(String) : [],
77
- newUsers: a.newUsers || 'approved',
78
- customize: Array.isArray(a.customize) ? a.customize : ['theme'],
79
- roles,
80
- users: a.users && typeof a.users === 'object' ? a.users : {}
81
- };
46
+ const a = cfg.access || {};
47
+ const roles = {};
48
+ for (const r of ROLES)
49
+ roles[r] = { ...DEFAULT_ROLES[r], ...(a.roles?.[r] || {}) };
50
+ return {
51
+ bootstrapAdmins: Array.isArray(a.bootstrapAdmins) ? a.bootstrapAdmins.map(String) : [],
52
+ newUsers: a.newUsers || 'approved',
53
+ customize: Array.isArray(a.customize) ? a.customize : ['theme'],
54
+ roles,
55
+ users: a.users && typeof a.users === 'object' ? a.users : {}
56
+ };
82
57
  }
83
-
84
58
  const expand = (list) => new Set(list === '*' ? FEATURES : list || []);
85
-
86
59
  /**
87
60
  * What a signed-in person may use. Tiers 1 and 2 are one person, who gets
88
61
  * everything that makes sense for one person: no user management.
89
62
  */
90
63
  export function featuresFor(cfg, role) {
91
- if (cfg.authMode !== 'trusted-header') {
92
- const all = new Set(FEATURES);
93
- all.delete('admin'); all.delete('oversight');
94
- return all;
95
- }
96
- return expand(resolveAccess(cfg).roles[role]?.features);
64
+ if (!['multiuser', 'trusted-header'].includes(cfg.authMode)) {
65
+ const all = new Set(FEATURES);
66
+ all.delete('admin');
67
+ all.delete('oversight');
68
+ return all;
69
+ }
70
+ return expand(role ? resolveAccess(cfg).roles[role]?.features : undefined);
97
71
  }
98
-
99
72
  /** '*' or the list of model ids this role may pick. */
100
73
  export function modelsFor(cfg, role) {
101
- if (cfg.authMode !== 'trusted-header') return '*';
102
- const m = resolveAccess(cfg).roles[role]?.models;
103
- return m === '*' || m === undefined ? '*' : m.map(String);
74
+ if (!['multiuser', 'trusted-header'].includes(cfg.authMode))
75
+ return '*';
76
+ const m = role ? resolveAccess(cfg).roles[role]?.models : undefined;
77
+ return m === '*' || m === undefined ? '*' : m.map(String);
104
78
  }
105
-
106
- export const MIN_PASSWORD_LENGTH = 15;
107
-
79
+ export const MIN_PASSWORD_LENGTH = 10;
108
80
  /** Every problem with a config, as readable strings. Empty means valid. */
81
+ // Takes the config as read, before anything about it is known to hold.
109
82
  export function validateConfig(cfg, { env = process.env } = {}) {
110
- const errors = [];
111
- const mode = cfg.authMode;
112
- if (mode === 'multiuser') {
113
- errors.push("authMode 'multiuser' has no login flow yet; use 'trusted-header' behind a gateway for Google/Apple/SSO sign-in");
114
- }
115
- if (mode === 'trusted-header' && !(Array.isArray(cfg.trustedProxyCidrs) && cfg.trustedProxyCidrs.length)) {
116
- errors.push("authMode 'trusted-header' requires trustedProxyCidrs: identity headers are only believed from those peers");
117
- }
118
- if (mode === 'single') {
119
- if (cfg.authPassword && !/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{64}$/i.test(String(cfg.authPassword))) {
120
- errors.push('authPassword must be a hash: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD');
121
- } else if (!cfg.authPassword && env.TINYWEBUI_PASSWORD && env.TINYWEBUI_PASSWORD.length < MIN_PASSWORD_LENGTH) {
122
- errors.push(`TINYWEBUI_PASSWORD needs at least ${MIN_PASSWORD_LENGTH} characters`);
123
- }
124
- }
125
-
126
- const a = cfg.access;
127
- if (a !== undefined) {
128
- if (!a || typeof a !== 'object' || Array.isArray(a)) return [...errors, 'access must be an object'];
129
- const known = new Set(['bootstrapAdmins', 'newUsers', 'roles', 'users', 'customize']);
130
- for (const k of Object.keys(a)) if (!known.has(k)) errors.push(`access.${k} is not a known setting`);
131
- if (a.bootstrapAdmins !== undefined && !(Array.isArray(a.bootstrapAdmins) && a.bootstrapAdmins.every((x) => typeof x === 'string' && x))) {
132
- errors.push('access.bootstrapAdmins must be a list of gateway user ids');
83
+ const errors = [];
84
+ const mode = cfg.authMode;
85
+ if (mode === 'trusted-header' && !(Array.isArray(cfg.trustedProxyCidrs) && cfg.trustedProxyCidrs.length)) {
86
+ errors.push("authMode 'trusted-header' requires trustedProxyCidrs: identity headers are only believed from those peers");
133
87
  }
134
- if (a.newUsers !== undefined && !['approved', 'pending'].includes(a.newUsers)) {
135
- errors.push("access.newUsers must be 'approved' or 'pending'");
136
- }
137
- if (a.customize !== undefined && !(Array.isArray(a.customize) && a.customize.every((c) => CUSTOMIZABLE.includes(c)))) {
138
- errors.push(`access.customize must be a list from ${CUSTOMIZABLE.join(', ')}`);
139
- }
140
- for (const [role, spec] of Object.entries(a.roles || {})) {
141
- if (!ROLES.includes(role)) { errors.push(`access.roles.${role}: unknown role (known: ${ROLES.join(', ')})`); continue; }
142
- if (!spec || typeof spec !== 'object') { errors.push(`access.roles.${role} must be an object`); continue; }
143
- for (const k of Object.keys(spec)) {
144
- if (!['features', 'models'].includes(k)) errors.push(`access.roles.${role}.${k} is not a known setting`);
145
- }
146
- if (spec.features !== undefined && spec.features !== '*') {
147
- if (!Array.isArray(spec.features)) errors.push(`access.roles.${role}.features must be '*' or a list`);
148
- else for (const f of spec.features) {
149
- if (!FEATURES.includes(f)) errors.push(`access.roles.${role}.features: unknown feature "${f}" (known: ${FEATURES.join(', ')})`);
88
+ if (mode === 'single')
89
+ errors.push("legacy authMode 'single' is no longer supported; choose multiuser for accounts or explicitly choose local without login");
90
+ if (mode === 'local' && cfg.authPassword)
91
+ errors.push("authMode 'local' cannot use authPassword; remove it only if you want access without login");
92
+ if (env.TINYWEBUI_PASSWORD)
93
+ errors.push('TINYWEBUI_PASSWORD no longer selects password login; unset it and use multiuser setup');
94
+ const a = cfg.access;
95
+ if (a !== undefined) {
96
+ if (!a || typeof a !== 'object' || Array.isArray(a))
97
+ return [...errors, 'access must be an object'];
98
+ const known = new Set(['bootstrapAdmins', 'newUsers', 'roles', 'users', 'customize']);
99
+ for (const k of Object.keys(a))
100
+ if (!known.has(k))
101
+ errors.push(`access.${k} is not a known setting`);
102
+ if (a.bootstrapAdmins !== undefined && !(Array.isArray(a.bootstrapAdmins) && a.bootstrapAdmins.every((x) => typeof x === 'string' && x))) {
103
+ errors.push('access.bootstrapAdmins must be a list of gateway user ids');
104
+ }
105
+ if (a.newUsers !== undefined && !['approved', 'pending'].includes(a.newUsers)) {
106
+ errors.push("access.newUsers must be 'approved' or 'pending'");
107
+ }
108
+ if (a.customize !== undefined && !(Array.isArray(a.customize) && a.customize.every((c) => CUSTOMIZABLE.includes(c)))) {
109
+ errors.push(`access.customize must be a list from ${CUSTOMIZABLE.join(', ')}`);
110
+ }
111
+ for (const [role, spec] of Object.entries(a.roles || {})) {
112
+ if (!ROLES.includes(role)) {
113
+ errors.push(`access.roles.${role}: unknown role (known: ${ROLES.join(', ')})`);
114
+ continue;
115
+ }
116
+ if (!spec || typeof spec !== 'object') {
117
+ errors.push(`access.roles.${role} must be an object`);
118
+ continue;
119
+ }
120
+ for (const k of Object.keys(spec)) {
121
+ if (!['features', 'models'].includes(k))
122
+ errors.push(`access.roles.${role}.${k} is not a known setting`);
123
+ }
124
+ if (spec.features !== undefined && spec.features !== '*') {
125
+ if (!Array.isArray(spec.features))
126
+ errors.push(`access.roles.${role}.features must be '*' or a list`);
127
+ else
128
+ for (const f of spec.features) {
129
+ if (!FEATURES.includes(f))
130
+ errors.push(`access.roles.${role}.features: unknown feature "${f}" (known: ${FEATURES.join(', ')})`);
131
+ }
132
+ }
133
+ if (spec.models !== undefined && spec.models !== '*' && !(Array.isArray(spec.models) && spec.models.every((m) => typeof m === 'string' && m))) {
134
+ errors.push(`access.roles.${role}.models must be '*' or a list of model ids (catalog ids when models is set)`);
135
+ }
136
+ }
137
+ for (const [id, u] of Object.entries(a.users || {})) {
138
+ if (!u || typeof u !== 'object') {
139
+ errors.push(`access.users["${id}"] must be an object`);
140
+ continue;
141
+ }
142
+ if (u.role !== undefined && !ROLES.includes(u.role))
143
+ errors.push(`access.users["${id}"].role must be one of ${ROLES.join(', ')}`);
144
+ if (u.status !== undefined && !STATUSES.includes(u.status))
145
+ errors.push(`access.users["${id}"].status must be one of ${STATUSES.join(', ')}`);
150
146
  }
151
- }
152
- if (spec.models !== undefined && spec.models !== '*' && !(Array.isArray(spec.models) && spec.models.every((m) => typeof m === 'string' && m))) {
153
- errors.push(`access.roles.${role}.models must be '*' or a list of model ids (catalog ids when models is set)`);
154
- }
155
- }
156
- for (const [id, u] of Object.entries(a.users || {})) {
157
- if (!u || typeof u !== 'object') { errors.push(`access.users["${id}"] must be an object`); continue; }
158
- if (u.role !== undefined && !ROLES.includes(u.role)) errors.push(`access.users["${id}"].role must be one of ${ROLES.join(', ')}`);
159
- if (u.status !== undefined && !STATUSES.includes(u.status)) errors.push(`access.users["${id}"].status must be one of ${STATUSES.join(', ')}`);
160
147
  }
161
- }
162
- return errors;
148
+ return errors;
163
149
  }
164
-
165
150
  /** Stable JSON: object keys sorted, so key order never changes a hash. */
166
151
  function canonical(value) {
167
- if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
168
- if (value && typeof value === 'object') {
169
- return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
170
- }
171
- return JSON.stringify(value ?? null);
152
+ if (Array.isArray(value))
153
+ return `[${value.map(canonical).join(',')}]`;
154
+ if (value && typeof value === 'object') {
155
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
156
+ }
157
+ return JSON.stringify(value ?? null);
172
158
  }
173
-
174
159
  /**
175
160
  * A short hash of the effective config and MCP servers. Secrets count only
176
161
  * as present/absent (their values never enter the hash), so a fingerprint can
177
162
  * be logged and compared against a checked-in config without leaking anything.
178
163
  */
179
164
  export function fingerprint(cfg, mcpServers = {}) {
180
- const redacted = {};
181
- for (const [k, v] of Object.entries(cfg)) {
182
- // The session secret is operational (it is generated on first start),
183
- // not policy; leaving it out keeps the CLI and the server in agreement.
184
- if (k === 'sessionSecret') continue;
185
- redacted[k] = SECRET_KEYS.has(k) ? Boolean(v) : v;
186
- }
187
- if (Array.isArray(redacted.connectors)) {
188
- redacted.connectors = redacted.connectors.map((c) => (c && typeof c === 'object' ? { ...c, apiKey: Boolean(c.apiKey) } : c));
189
- }
190
- return createHash('sha256').update(canonical({ config: redacted, mcpServers })).digest('hex').slice(0, 16);
165
+ const redacted = {};
166
+ for (const [k, v] of Object.entries(cfg)) {
167
+ // The session secret is operational (it is generated on first start),
168
+ // not policy; leaving it out keeps the CLI and the server in agreement.
169
+ if (k === 'sessionSecret')
170
+ continue;
171
+ redacted[k] = SECRET_KEYS.has(k) ? Boolean(v) : v;
172
+ }
173
+ if (Array.isArray(redacted.connectors)) {
174
+ redacted.connectors = redacted.connectors.map((c) => (c && typeof c === 'object' ? { ...c, apiKey: Boolean(c.apiKey) } : c));
175
+ }
176
+ return createHash('sha256').update(canonical({ config: redacted, mcpServers })).digest('hex').slice(0, 16);
191
177
  }
192
-
193
178
  /** 'file-only' | 'frozen' | 'editable' -- how a key may be changed. */
194
179
  export function keyClass(key, lockedKeys) {
195
- if (FILE_ONLY.has(key)) return 'file-only';
196
- if (lockedKeys.has(key)) return 'frozen';
197
- return 'editable';
180
+ if (FILE_ONLY.has(key))
181
+ return 'file-only';
182
+ if (lockedKeys.has(key))
183
+ return 'frozen';
184
+ return 'editable';
198
185
  }
package/src/audit.js CHANGED
@@ -3,5 +3,5 @@
3
3
  * log collector. Never pass content, prompts, tokens or raw header values.
4
4
  */
5
5
  export function audit(event, fields = {}) {
6
- console.log(`[tinywebui:audit] ${JSON.stringify({ ts: new Date().toISOString(), event, ...fields })}`);
6
+ console.log(`[tinywebui:audit] ${JSON.stringify({ ts: new Date().toISOString(), event, ...fields })}`);
7
7
  }
@@ -1,136 +1,148 @@
1
1
  import { CronExpressionParser } from 'cron-parser';
2
-
3
2
  export function validateSchedule(cron, timezone, now = Date.now()) {
4
- const tz = String(timezone || '').trim();
5
- if (!tz) throw new Error('timezone is required');
6
- try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); }
7
- catch { throw new Error(`invalid IANA timezone: ${tz}`); }
8
- let nextRunAt;
9
- try {
10
- nextRunAt = CronExpressionParser.parse(String(cron || ''), {
11
- currentDate: new Date(now), tz
12
- }).next().getTime();
13
- } catch (err) {
14
- throw new Error(`invalid cron schedule: ${err.message}`);
15
- }
16
- return { cron: String(cron).trim(), timezone: tz, nextRunAt };
3
+ const tz = String(timezone || '').trim();
4
+ if (!tz)
5
+ throw new Error('timezone is required');
6
+ try {
7
+ new Intl.DateTimeFormat('en-US', { timeZone: tz });
8
+ }
9
+ catch {
10
+ throw new Error(`invalid IANA timezone: ${tz}`);
11
+ }
12
+ let nextRunAt;
13
+ try {
14
+ nextRunAt = CronExpressionParser.parse(String(cron || ''), {
15
+ currentDate: new Date(now), tz
16
+ }).next().getTime();
17
+ }
18
+ catch (err) {
19
+ throw new Error(`invalid cron schedule: ${err.message}`);
20
+ }
21
+ return { cron: String(cron).trim(), timezone: tz, nextRunAt };
17
22
  }
18
-
19
23
  function cleanFields(input, { requireAll = false } = {}) {
20
- const fields = {};
21
- for (const key of ['name', 'prompt']) {
22
- if (input[key] === undefined && !requireAll) continue;
23
- const val = String(input[key] ?? '').trim();
24
- if (!val) throw new Error(`${key} is required`);
25
- if (val.length > (key === 'prompt' ? 12000 : 120)) throw new Error(`${key} is too long`);
26
- fields[key] = val;
27
- }
28
- if (requireAll || (input.cron !== undefined && input.timezone !== undefined)) {
29
- const cron = input.cron ?? '';
30
- const timezone = input.timezone ?? '';
31
- Object.assign(fields, validateSchedule(cron, timezone));
32
- }
33
- if (input.enabled !== undefined) fields.enabled = Boolean(input.enabled);
34
- return fields;
24
+ const fields = {};
25
+ for (const key of ['name', 'prompt']) {
26
+ if (input[key] === undefined && !requireAll)
27
+ continue;
28
+ const val = String(input[key] ?? '').trim();
29
+ if (!val)
30
+ throw new Error(`${key} is required`);
31
+ if (val.length > (key === 'prompt' ? 12000 : 120))
32
+ throw new Error(`${key} is too long`);
33
+ fields[key] = val;
34
+ }
35
+ if (requireAll || (input.cron !== undefined && input.timezone !== undefined)) {
36
+ const cron = input.cron ?? '';
37
+ const timezone = input.timezone ?? '';
38
+ Object.assign(fields, validateSchedule(cron, timezone));
39
+ }
40
+ if (input.enabled !== undefined)
41
+ fields.enabled = Boolean(input.enabled);
42
+ return fields;
35
43
  }
36
-
37
44
  export function automationToolDef() {
38
- return {
39
- type: 'function',
40
- function: {
41
- name: 'manage_automation',
42
- description: [
43
- 'Manage scheduled automations: a prompt that runs on its own, on a cron schedule, in this chat.',
44
- 'Use it when the user wants something done repeatedly or later without them ("every morning',
45
- 'summarise...", "check X each Friday"). For steps of the current request use manage_tasks instead.',
46
- '',
47
- 'Each run starts fresh: it sees only its own prompt and the previous run\'s result, not this',
48
- 'conversation, and nobody is there to answer questions. Write `prompt` so it stands alone:',
49
- 'what to do, where, and what to report.',
50
- '',
51
- 'Actions and what each needs:',
52
- '- list: nothing. Returns every automation with its id, schedule and last result.',
53
- '- create: name, prompt, cron, timezone. Starts running on schedule immediately.',
54
- '- update: id, plus only the fields to change. cron and timezone are checked together.',
55
- '- delete: id. Permanent.',
56
- '- trigger: id. Runs it once now; queued if the chat is busy.',
57
- '',
58
- 'An automation keeps running until someone stops it, so before create or delete, make sure',
59
- 'the user asked for exactly that (schedule, timezone, what it does). If any of those is unclear,',
60
- 'ask first. Pausing is update with enabled=false.'
61
- ].join('\n'),
62
- parameters: {
63
- type: 'object',
64
- properties: {
65
- action: { type: 'string', enum: ['list', 'create', 'update', 'delete', 'trigger'] },
66
- id: { type: 'string', description: 'The automation\'s id, from list or create. For update, delete and trigger.' },
67
- name: { type: 'string', description: 'Short label shown to the user, e.g. "Morning inbox summary". Up to 120 characters.' },
68
- prompt: { type: 'string', description: 'The instruction each run carries out, written to stand alone (see above). Up to 12000 characters.' },
69
- cron: { type: 'string', description: 'Five fields: minute hour day-of-month month day-of-week. "0 9 * * 1-5" is 09:00 on weekdays; "*/30 * * * *" is every 30 minutes.' },
70
- timezone: { type: 'string', description: 'IANA timezone the cron is read in, e.g. "Europe/Vienna". Required with cron; use the user\'s timezone, and ask if you do not know it.' },
71
- enabled: { type: 'boolean', description: 'false pauses the automation without deleting it; true resumes it. New automations are enabled.' }
72
- },
73
- required: ['action']
74
- }
75
- }
76
- };
45
+ return {
46
+ type: 'function',
47
+ function: {
48
+ name: 'manage_automation',
49
+ description: [
50
+ 'Manage scheduled automations: a prompt that runs on its own, on a cron schedule, in this chat.',
51
+ 'Use it when the user wants something done repeatedly or later without them ("every morning',
52
+ 'summarise...", "check X each Friday"). For steps of the current request use manage_tasks instead.',
53
+ '',
54
+ 'Each run starts fresh: it sees only its own prompt and the previous run\'s result, not this',
55
+ 'conversation, and nobody is there to answer questions. Write `prompt` so it stands alone:',
56
+ 'what to do, where, and what to report.',
57
+ '',
58
+ 'Actions and what each needs:',
59
+ '- list: nothing. Returns every automation with its id, schedule and last result.',
60
+ '- create: name, prompt, cron, timezone. Starts running on schedule immediately.',
61
+ '- update: id, plus only the fields to change. cron and timezone are checked together.',
62
+ '- delete: id. Permanent.',
63
+ '- trigger: id. Runs it once now; queued if the chat is busy.',
64
+ '',
65
+ 'An automation keeps running until someone stops it, so before create or delete, make sure',
66
+ 'the user asked for exactly that (schedule, timezone, what it does). If any of those is unclear,',
67
+ 'ask first. Pausing is update with enabled=false.'
68
+ ].join('\n'),
69
+ parameters: {
70
+ type: 'object',
71
+ properties: {
72
+ action: { type: 'string', enum: ['list', 'create', 'update', 'delete', 'trigger'] },
73
+ id: { type: 'string', description: 'The automation\'s id, from list or create. For update, delete and trigger.' },
74
+ name: { type: 'string', description: 'Short label shown to the user, e.g. "Morning inbox summary". Up to 120 characters.' },
75
+ prompt: { type: 'string', description: 'The instruction each run carries out, written to stand alone (see above). Up to 12000 characters.' },
76
+ cron: { type: 'string', description: 'Five fields: minute hour day-of-month month day-of-week. "0 9 * * 1-5" is 09:00 on weekdays; "*/30 * * * *" is every 30 minutes.' },
77
+ timezone: { type: 'string', description: 'IANA timezone the cron is read in, e.g. "Europe/Vienna". Required with cron; use the user\'s timezone, and ask if you do not know it.' },
78
+ enabled: { type: 'boolean', description: 'false pauses the automation without deleting it; true resumes it. New automations are enabled.' }
79
+ },
80
+ required: ['action']
81
+ }
82
+ }
83
+ };
77
84
  }
78
-
79
- export async function manageAutomation(args, { store, chatId, triggerAutomation, unattended = false } = {}) {
80
- const action = String(args?.action || '');
81
- // A scheduled run has nobody watching it. Letting it start more runs is how
82
- // one trigger becomes an endless self-requeueing loop, and how text injected
83
- // through a tool result turns into a standing schedule. Adjusting or removing
84
- // an existing automation stays allowed -- "stop once X is done" is legitimate.
85
- if (unattended && (action === 'create' || action === 'trigger')) {
86
- return `Error: "${action}" is not available inside a scheduled run. Tell the user in your result if a new automation or run is needed.`;
87
- }
88
- const chat = chatId ? store.chats.byId(chatId) : null;
89
- const userId = chat?.user_id ?? null;
90
- if (action === 'list') return JSON.stringify(store.automations.list(userId));
91
- if (action === 'create') {
92
- if (!chat) throw new Error('automation creation requires a current chat');
93
- const fields = cleanFields(args, { requireAll: true });
94
- const row = store.automations.create({ ...fields, chatId, source: 'model' }, userId);
95
- return JSON.stringify(row);
96
- }
97
- if (!args?.id) throw new Error('id is required');
98
- if (action === 'delete') return store.automations.delete(args.id, userId)
99
- ? `Deleted automation ${args.id}.` : 'Error: no such automation';
100
- if (action === 'trigger') {
101
- const automation = store.automations.get(args.id, userId);
102
- if (!automation) throw new Error('no such automation');
103
- if (!triggerAutomation) throw new Error('manual triggering is unavailable');
104
- return JSON.stringify(await triggerAutomation(automation, userId));
105
- }
106
- if (action === 'update') {
107
- const current = store.automations.get(args.id, userId);
108
- if (!current) throw new Error('no such automation');
109
- const fields = cleanFields(args);
110
- if (args.cron !== undefined || args.timezone !== undefined) {
111
- Object.assign(fields, validateSchedule(fields.cron ?? current.cron, fields.timezone ?? current.timezone));
85
+ export async function manageAutomation(args, { store, chatId, triggerAutomation, unattended = false }) {
86
+ const action = String(args?.action || '');
87
+ // A scheduled run has nobody watching it. Letting it start more runs is how
88
+ // one trigger becomes an endless self-requeueing loop, and how text injected
89
+ // through a tool result turns into a standing schedule. Adjusting or removing
90
+ // an existing automation stays allowed -- "stop once X is done" is legitimate.
91
+ if (unattended && (action === 'create' || action === 'trigger')) {
92
+ return `Error: "${action}" is not available inside a scheduled run. Tell the user in your result if a new automation or run is needed.`;
93
+ }
94
+ const chat = chatId ? store.chats.byId(chatId) : null;
95
+ const userId = chat?.user_id ?? null;
96
+ if (action === 'list')
97
+ return JSON.stringify(store.automations.list(userId));
98
+ if (action === 'create') {
99
+ if (!chat)
100
+ throw new Error('automation creation requires a current chat');
101
+ const fields = cleanFields(args, { requireAll: true });
102
+ // requireAll: cleanFields has filled every field create needs, or thrown.
103
+ const row = store.automations.create({ ...fields, chatId, source: 'model' }, userId);
104
+ return JSON.stringify(row);
112
105
  }
113
- const row = store.automations.update(args.id, fields, userId);
114
- return row ? JSON.stringify(row) : 'Error: no such automation';
115
- }
116
- throw new Error('action must be list, create, update, delete, or trigger');
106
+ if (!args?.id)
107
+ throw new Error('id is required');
108
+ if (action === 'delete')
109
+ return store.automations.delete(args.id, userId)
110
+ ? `Deleted automation ${args.id}.` : 'Error: no such automation';
111
+ if (action === 'trigger') {
112
+ const automation = store.automations.get(args.id, userId);
113
+ if (!automation)
114
+ throw new Error('no such automation');
115
+ if (!triggerAutomation)
116
+ throw new Error('manual triggering is unavailable');
117
+ return JSON.stringify(await triggerAutomation(automation, userId));
118
+ }
119
+ if (action === 'update') {
120
+ const current = store.automations.get(args.id, userId);
121
+ if (!current)
122
+ throw new Error('no such automation');
123
+ const fields = cleanFields(args);
124
+ if (args.cron !== undefined || args.timezone !== undefined) {
125
+ Object.assign(fields, validateSchedule(fields.cron ?? current.cron, fields.timezone ?? current.timezone));
126
+ }
127
+ const row = store.automations.update(args.id, fields, userId);
128
+ return row ? JSON.stringify(row) : 'Error: no such automation';
129
+ }
130
+ throw new Error('action must be list, create, update, delete, or trigger');
117
131
  }
118
-
119
132
  export function nextSchedule(cron, timezone, now = Date.now()) {
120
- return validateSchedule(cron, timezone, now).nextRunAt;
133
+ return validateSchedule(cron, timezone, now).nextRunAt;
121
134
  }
122
-
123
135
  export function runMessage(automation) {
124
- const previous = automation.lastResult
125
- ? `\n\nPrevious run result (truncated):\n${automation.lastResult.slice(0, 2000)}` : '';
126
- return [
127
- `[Scheduled automation: ${automation.name}]`,
128
- `Schedule: ${automation.cron} (${automation.timezone})`,
129
- // Without this the model reads an ordinary user turn and may stop to ask a
130
- // clarifying question that nobody will ever answer.
131
- 'This is an unattended run: no one is present to answer questions. Complete the task'
132
- + ' with sensible assumptions and state them, or report exactly what blocked it.',
133
- '',
134
- automation.prompt
135
- ].join('\n') + previous;
136
+ const previous = automation.lastResult
137
+ ? `\n\nPrevious run result (truncated):\n${automation.lastResult.slice(0, 2000)}` : '';
138
+ return [
139
+ `[Scheduled automation: ${automation.name}]`,
140
+ `Schedule: ${automation.cron} (${automation.timezone})`,
141
+ // Without this the model reads an ordinary user turn and may stop to ask a
142
+ // clarifying question that nobody will ever answer.
143
+ 'This is an unattended run: no one is present to answer questions. Complete the task'
144
+ + ' with sensible assumptions and state them, or report exactly what blocked it.',
145
+ '',
146
+ automation.prompt
147
+ ].join('\n') + previous;
136
148
  }