wisp-agents 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -1
- package/README.md +44 -4
- package/bin/wisp.js +55 -1
- package/package.json +2 -2
- package/server/adapters/codex.js +3 -0
- package/server/client.js +2 -2
- package/server/index.js +17 -2
- package/server/mcp-ask.js +44 -2
- package/server/openapi.js +39 -1
- package/server/protocol.js +32 -3
- package/server/render.js +16 -1
- package/server/routes/automations.js +8 -5
- package/server/routes/internal.js +61 -0
- package/server/routes/meta.js +5 -1
- package/server/routes/settings.js +17 -3
- package/server/routes/spells.js +147 -0
- package/server/runner.js +10 -3
- package/server/services/launch.js +7 -2
- package/server/services/spell-hooks.js +99 -0
- package/server/spell-guide.js +135 -0
- package/server/spells-data.js +60 -0
- package/server/spells.js +647 -0
- package/server/update.js +50 -16
- package/web/css/feed.css +1 -1
- package/web/css/spells.css +205 -0
- package/web/index.html +4 -0
- package/web/js/app.js +3 -0
- package/web/js/blocks.js +7 -1
- package/web/js/features/actions.js +3 -0
- package/web/js/features/commands.js +11 -0
- package/web/js/features/composer.js +12 -0
- package/web/js/features/feed.js +3 -0
- package/web/js/features/keys.js +15 -0
- package/web/js/features/launcher.js +56 -11
- package/web/js/features/live.js +9 -1
- package/web/js/features/settings.js +21 -0
- package/web/js/features/slash.js +2 -1
- package/web/js/features/spell-actions.js +64 -0
- package/web/js/features/spell-ui.js +398 -0
- package/web/js/features/spells.js +715 -0
- package/web/js/features/turns.js +2 -0
- package/web/js/features/update.js +1 -2
- package/web/js/spell-schema.js +246 -0
package/server/spells.js
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
// Spells: changes to wisp you ask for (or get from a colleague) that wisp layers over itself at
|
|
2
|
+
// runtime. A spell is one JSON file in ~/.wisp/spells/<id>.json; ~/.wisp/spells.json says which are
|
|
3
|
+
// on. Nothing a spell adds is written into config.json: wisp merges spells in when it reads, so
|
|
4
|
+
// turning one off puts wisp back exactly where it was, and the file is all there is to share.
|
|
5
|
+
//
|
|
6
|
+
// What a spell may contain is checked here (normalizeSpell) against web/js/spell-schema.js, the
|
|
7
|
+
// table the page uses to apply them. describeSpell() says in plain words what one does, for the
|
|
8
|
+
// review dialog and the Cast a spell agent.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import {
|
|
14
|
+
ACTIONS, BUILTIN_BLOCKS, CARD_HEAD_ORDER, EVENTS, HOOK_ACTIONS, HOOK_IF, ICONS, PART_PROPS, PARTS, SETTINGS, SLOTS, SPELL_VERSION, TOKENS,
|
|
15
|
+
ghProblem, kebab, scriptBody, scriptExports, styleProblem, wispPathProblem,
|
|
16
|
+
} from '../web/js/spell-schema.js';
|
|
17
|
+
import { normalizeRecipe } from './recipes.js';
|
|
18
|
+
import { bad, clip, truncate, writeJsonAtomic } from './util.js';
|
|
19
|
+
|
|
20
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,40}$/;
|
|
21
|
+
const MAX_SCRIPT = 64 * 1024;
|
|
22
|
+
const MAX_ITEMS = 40; // per list in a spell
|
|
23
|
+
const MAX_PROMPT = 600; // characters per prompt rule
|
|
24
|
+
const MAX_DOC = 400; // characters per agent block doc
|
|
25
|
+
|
|
26
|
+
const str = (v, n = 200) => (v === undefined || v === null ? '' : clip(String(v), n));
|
|
27
|
+
const arr = (v) => (Array.isArray(v) ? v : v === undefined || v === null ? [] : [v]);
|
|
28
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
|
|
29
|
+
|
|
30
|
+
// One problem list per spell: every check pushes a message, and a spell with any is refused.
|
|
31
|
+
class Problems extends Array {
|
|
32
|
+
at_(where, msg) {
|
|
33
|
+
this.push(`${where}: ${msg}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function checkAction(a, where, p, { hook = false, panels = new Set(), data = new Set(), fns = new Set() } = {}) {
|
|
38
|
+
const o = obj(a);
|
|
39
|
+
if (!o) return p.at_(where, 'do must be an object like { "open": "https://…" }'), null;
|
|
40
|
+
const keys = Object.keys(o).filter((k) => k in ACTIONS);
|
|
41
|
+
if (keys.length !== 1) return p.at_(where, `do needs exactly one of ${Object.keys(ACTIONS).join(', ')}`), null;
|
|
42
|
+
const k = keys[0];
|
|
43
|
+
const v = o[k];
|
|
44
|
+
if (hook && !HOOK_ACTIONS.has(k)) return p.at_(where, `a hook can only ${[...HOOK_ACTIONS].join(', ')}`), null;
|
|
45
|
+
const text = (x) => typeof x === 'string' && x.trim();
|
|
46
|
+
switch (k) {
|
|
47
|
+
case 'open':
|
|
48
|
+
case 'webhook':
|
|
49
|
+
// A link, or a template that becomes one ({pr.url}); the page checks the filled-in link again.
|
|
50
|
+
if (!text(v) || !(/^https?:\/\//i.test(v.trim()) || (k === 'open' && /^\{[\w.]+\}/.test(v.trim())))) p.at_(where, `${k} needs an http(s) URL`);
|
|
51
|
+
return { [k]: str(v, 500) };
|
|
52
|
+
case 'openAll': {
|
|
53
|
+
const x = obj(v);
|
|
54
|
+
// { fn, with } (or `data`, as stored): links a script function returns from those sources.
|
|
55
|
+
if (x?.fn) {
|
|
56
|
+
if (!fns.has(x.fn)) p.at_(where, `openAll: the script exports no function ${x.fn}`);
|
|
57
|
+
return { openAll: { fn: str(x.fn, 60), data: arr(x.with ?? x.data).map((d) => str(d, 40)) } };
|
|
58
|
+
}
|
|
59
|
+
if (x?.data) {
|
|
60
|
+
if (!data.has(x.data)) p.at_(where, `openAll: no data source named ${x.data}`);
|
|
61
|
+
return { openAll: { data: str(x.data, 40), field: str(x.field || 'url', 60) } };
|
|
62
|
+
}
|
|
63
|
+
p.at_(where, 'openAll needs { "data": "<source>", "field": "url" } or { "fn": "<script function>" }');
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
case 'panel':
|
|
67
|
+
if (!panels.has(v)) p.at_(where, `no panel named ${v}`);
|
|
68
|
+
return { panel: str(v, 40) };
|
|
69
|
+
case 'insert':
|
|
70
|
+
case 'copy':
|
|
71
|
+
case 'toast':
|
|
72
|
+
case 'send':
|
|
73
|
+
case 'reply':
|
|
74
|
+
case 'notify':
|
|
75
|
+
case 'exec':
|
|
76
|
+
case 'recipe':
|
|
77
|
+
if (!text(v)) p.at_(where, `${k} needs text`);
|
|
78
|
+
// Kept as written (line breaks, a trailing space after "Explain why "), only capped.
|
|
79
|
+
return { [k]: k === 'exec' ? str(v, 1000) : truncate(String(v), 4000) };
|
|
80
|
+
case 'ship':
|
|
81
|
+
case 'done':
|
|
82
|
+
case 'archive':
|
|
83
|
+
return { [k]: true };
|
|
84
|
+
case 'setLaunch':
|
|
85
|
+
case 'launchWith':
|
|
86
|
+
case 'rerun':
|
|
87
|
+
case 'launch': {
|
|
88
|
+
const x = obj(v) || {};
|
|
89
|
+
const out = {};
|
|
90
|
+
for (const f of ['agent', 'model', 'effort', 'mode', 'text', 'prompt', 'cwd']) if (x[f] !== undefined) out[f] = f === 'prompt' || f === 'text' ? truncate(String(x[f]), 4000) : str(x[f], 200);
|
|
91
|
+
if (out.mode && !['plan', 'edits', 'auto'].includes(out.mode)) p.at_(where, 'mode must be plan, edits or auto');
|
|
92
|
+
if (k === 'launch' && !out.prompt) p.at_(where, 'launch needs a prompt');
|
|
93
|
+
return { [k]: out };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The kind a spell's riskiest part makes it: safe < acts < reads < runs.
|
|
100
|
+
const RANK = { safe: 0, acts: 1, reads: 2, runs: 3 };
|
|
101
|
+
|
|
102
|
+
// Validate and normalize a spell. Throws a 400 listing every problem.
|
|
103
|
+
export function normalizeSpell(raw) {
|
|
104
|
+
const p = new Problems();
|
|
105
|
+
const j = obj(raw);
|
|
106
|
+
if (!j) throw bad('A spell is a JSON object.');
|
|
107
|
+
if (j.wispSpell !== SPELL_VERSION) p.at_('wispSpell', `must be ${SPELL_VERSION}`);
|
|
108
|
+
const id = String(j.id || '').trim();
|
|
109
|
+
if (!ID.test(id)) p.at_('id', 'lowercase letters, digits and dashes, starting with a letter or digit (at most 41)');
|
|
110
|
+
const name = str(j.name, 60).trim();
|
|
111
|
+
if (!name) p.at_('name', 'required');
|
|
112
|
+
const known = new Set(['wispSpell', 'id', 'name', 'version', 'description', 'author', 'wisp', 'harnesses', 'when', 'data', 'buttons', 'chips', 'panels', 'blocks', 'parts', 'ui', 'prompt', 'settings', 'slash', 'commands', 'hooks', 'recipes', 'script']);
|
|
113
|
+
const warnings = Object.keys(j).filter((k) => !known.has(k)).map((k) => `${k}: not something spells know about yet, ignored`);
|
|
114
|
+
|
|
115
|
+
// script: plain JS whose exported functions render panels, chips and blocks in the sandbox.
|
|
116
|
+
const script = typeof j.script === 'string' ? j.script : '';
|
|
117
|
+
if (script.length > MAX_SCRIPT) p.at_('script', `at most ${MAX_SCRIPT / 1024} KB`);
|
|
118
|
+
const fns = new Set(scriptExports(script));
|
|
119
|
+
if (script) {
|
|
120
|
+
try {
|
|
121
|
+
new Function(scriptBody(script)); // compiles; never called here
|
|
122
|
+
} catch (err) {
|
|
123
|
+
p.at_('script', `doesn't compile: ${err.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const needFn = (fn, where) => fn && !fns.has(fn) && p.at_(where, `the script exports no function ${fn}`);
|
|
127
|
+
|
|
128
|
+
// data: what wisp fetches for the spell. Mod code never fetches anything itself.
|
|
129
|
+
const data = {};
|
|
130
|
+
for (const [k, v] of Object.entries(obj(j.data) || {})) {
|
|
131
|
+
const where = `data.${k}`;
|
|
132
|
+
const x = obj(v);
|
|
133
|
+
if (!/^[A-Za-z_][\w]{0,30}$/.test(k) || !x) {
|
|
134
|
+
p.at_(where, 'a source is { "wisp": "/sessions" } or { "gh": ["pr", "list", …] }');
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const refresh = Math.min(Math.max(Number(x.refresh) || 60, 10), 3600);
|
|
138
|
+
if (x.wisp !== undefined) {
|
|
139
|
+
const e = wispPathProblem(x.wisp);
|
|
140
|
+
if (e) p.at_(where, e);
|
|
141
|
+
data[k] = { wisp: String(x.wisp), refresh };
|
|
142
|
+
} else if (x.gh !== undefined) {
|
|
143
|
+
const e = ghProblem(x.gh);
|
|
144
|
+
if (e) p.at_(where, e);
|
|
145
|
+
data[k] = { gh: x.gh.map(String), refresh, ...(x.cwd === 'task' && { cwd: 'task' }) };
|
|
146
|
+
} else p.at_(where, 'a source is { "wisp": "/path" } or { "gh": [args] }');
|
|
147
|
+
}
|
|
148
|
+
const dataNames = new Set(Object.keys(data));
|
|
149
|
+
|
|
150
|
+
const panelIds = new Set(arr(j.panels).map((x) => x?.id).filter(Boolean));
|
|
151
|
+
const ctx = { panels: panelIds, data: dataNames, fns };
|
|
152
|
+
|
|
153
|
+
const icon = (v, where) => {
|
|
154
|
+
if (v === undefined || v === '') return '';
|
|
155
|
+
if (!ICONS.includes(v)) p.at_(where, `icon must be one of ${ICONS.join(', ')}`);
|
|
156
|
+
return String(v);
|
|
157
|
+
};
|
|
158
|
+
const when = (v) => (obj(v) ? Object.fromEntries(Object.entries(v).filter(([k]) => ['hasPr', 'prState', 'state', 'status', 'agent', 'project', 'unread', 'hasBranch'].includes(k))) : undefined);
|
|
159
|
+
const slotOk = (slot, where, allowed) => {
|
|
160
|
+
if (!(slot in SLOTS)) p.at_(where, `slot must be one of ${Object.keys(SLOTS).join(', ')}`);
|
|
161
|
+
else if (allowed && !allowed.includes(slot)) p.at_(where, `this goes in ${allowed.join(', ')}`);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const BUTTON_SLOTS = ['topbar', 'float', 'palette', 'launcher.bar', 'launcher.chips', 'composer.bar', 'composer.chips', 'card.hover', 'card.menu'];
|
|
165
|
+
const buttons = arr(j.buttons).slice(0, MAX_ITEMS).map((b, i) => {
|
|
166
|
+
const where = `buttons[${i}]`;
|
|
167
|
+
const x = obj(b) || {};
|
|
168
|
+
slotOk(x.slot, where, BUTTON_SLOTS);
|
|
169
|
+
const label = str(x.label, 40);
|
|
170
|
+
if (!label && !x.icon) p.at_(where, 'needs a label or an icon');
|
|
171
|
+
const badge = obj(x.badge);
|
|
172
|
+
if (badge?.count && !dataNames.has(badge.count)) p.at_(where, `badge counts ${badge.count}, which isn't a data source`);
|
|
173
|
+
if (badge?.fn) needFn(badge.fn, where);
|
|
174
|
+
return {
|
|
175
|
+
id: str(x.id || `b${i}`, 40),
|
|
176
|
+
slot: x.slot,
|
|
177
|
+
label,
|
|
178
|
+
icon: icon(x.icon, where),
|
|
179
|
+
title: str(x.title, 120),
|
|
180
|
+
...(badge && { badge: badge.count ? { count: str(badge.count, 40) } : badge.fn ? { fn: str(badge.fn, 60), data: arr(badge.with ?? badge.data).map((d) => str(d, 40)) } : { text: str(badge.text, 40) } }),
|
|
181
|
+
...(x.group && { group: str(x.group, 30) }),
|
|
182
|
+
...(x.corner && { corner: x.corner === 'bottom-left' ? 'bottom-left' : 'bottom-right' }),
|
|
183
|
+
...(x.key && { key: str(x.key, 20) }),
|
|
184
|
+
...(when(x.when) && { when: when(x.when) }),
|
|
185
|
+
...(x.primary && { primary: true }),
|
|
186
|
+
do: checkAction(x.do, where, p, ctx),
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const chips = arr(j.chips).slice(0, MAX_ITEMS).map((c, i) => {
|
|
191
|
+
const where = `chips[${i}]`;
|
|
192
|
+
const x = obj(c) || {};
|
|
193
|
+
slotOk(x.slot, where, ['card.head', 'launcher.chips', 'composer.chips']);
|
|
194
|
+
if (!x.text && !x.fn) p.at_(where, 'needs text (a {field} template) or fn');
|
|
195
|
+
needFn(x.fn, where);
|
|
196
|
+
return {
|
|
197
|
+
id: str(x.id || `c${i}`, 40),
|
|
198
|
+
slot: x.slot,
|
|
199
|
+
text: str(x.text, 120),
|
|
200
|
+
...(x.fn && { fn: str(x.fn, 60), data: arr(x.with ?? x.data).map((d) => str(d, 40)) }),
|
|
201
|
+
tone: ['good', 'bad', 'warn', 'info', 'neutral'].includes(x.tone) ? x.tone : 'neutral',
|
|
202
|
+
icon: icon(x.icon, where),
|
|
203
|
+
title: str(x.title, 160),
|
|
204
|
+
...(when(x.when) && { when: when(x.when) }),
|
|
205
|
+
...(x.do && { do: checkAction(x.do, where, p, ctx) }),
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const panels = arr(j.panels).slice(0, MAX_ITEMS).map((x0, i) => {
|
|
210
|
+
const x = obj(x0) || {};
|
|
211
|
+
const where = `panels[${i}]`;
|
|
212
|
+
if (!x.id) p.at_(where, 'needs an id');
|
|
213
|
+
const as = ['dialog', 'drawer', 'feed', 'card'].includes(x.as) ? x.as : 'dialog';
|
|
214
|
+
if (as === 'card' && !['card.top', 'card.bottom'].includes(x.slot)) p.at_(where, 'a card panel needs slot card.top or card.bottom');
|
|
215
|
+
if (!x.render) p.at_(where, 'needs render: the script function that returns its blocks');
|
|
216
|
+
needFn(x.render, where);
|
|
217
|
+
for (const d of arr(x.data)) if (!dataNames.has(d)) p.at_(where, `no data source named ${d}`);
|
|
218
|
+
const refresh = obj(x.refresh) || {};
|
|
219
|
+
return {
|
|
220
|
+
id: str(x.id, 40),
|
|
221
|
+
title: str(x.title, 80),
|
|
222
|
+
icon: icon(x.icon, where),
|
|
223
|
+
as,
|
|
224
|
+
...(as === 'card' && { slot: x.slot }),
|
|
225
|
+
data: arr(x.data).map((d) => str(d, 40)),
|
|
226
|
+
render: str(x.render, 60),
|
|
227
|
+
...(when(x.when) && { when: when(x.when) }),
|
|
228
|
+
refresh: { ...(refresh.every && { every: Math.min(Math.max(Number(refresh.every) || 60, 10), 3600) }), on: arr(refresh.on).filter((e) => ['summary', 'finished', 'config'].includes(e)) },
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const blocks = arr(j.blocks).slice(0, MAX_ITEMS).map((x0, i) => {
|
|
233
|
+
const x = obj(x0) || {};
|
|
234
|
+
const where = `blocks[${i}]`;
|
|
235
|
+
const type = String(x.type || '');
|
|
236
|
+
if (!/^[a-z][a-z0-9-]{1,30}$/.test(type)) p.at_(where, 'type: lowercase letters, digits and dashes');
|
|
237
|
+
if (BUILTIN_BLOCKS.includes(type)) p.at_(where, `${type} is a built-in block`);
|
|
238
|
+
if (!x.doc) p.at_(where, 'needs doc: a JSON example and when an agent should use it');
|
|
239
|
+
if (!x.render) p.at_(where, 'needs render: the script function that turns it into wisp blocks');
|
|
240
|
+
needFn(x.render, where);
|
|
241
|
+
return { type, doc: str(x.doc, MAX_DOC), summary: str(x.summary, 200), render: str(x.render, 60) };
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// parts: restyle, props and order; ui: tokens and hide.
|
|
245
|
+
const parts = {};
|
|
246
|
+
for (const [name, v] of Object.entries(obj(j.parts) || {})) {
|
|
247
|
+
const where = `parts.${name}`;
|
|
248
|
+
if (!(name in PARTS)) {
|
|
249
|
+
p.at_(where, `not a part (see the part list)`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const x = obj(v) || {};
|
|
253
|
+
const out = {};
|
|
254
|
+
if (x.style) {
|
|
255
|
+
out.style = {};
|
|
256
|
+
for (const [k, val] of Object.entries(obj(x.style) || {})) {
|
|
257
|
+
const e = styleProblem(k, val);
|
|
258
|
+
if (e) p.at_(where, e);
|
|
259
|
+
else out.style[kebab(k)] = String(val).trim();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (x.props) {
|
|
263
|
+
out.props = {};
|
|
264
|
+
const allowed = PART_PROPS[name] || {};
|
|
265
|
+
for (const [k, val] of Object.entries(obj(x.props) || {})) {
|
|
266
|
+
const t = allowed[k];
|
|
267
|
+
if (!t) p.at_(where, `${k} isn't a prop of ${name}${Object.keys(allowed).length ? ` (${Object.keys(allowed).join(', ')})` : ''}`);
|
|
268
|
+
else if (Array.isArray(t) ? !t.includes(val) : t === 'int' ? !Number.isInteger(val) || val < 1 || val > 40 : t === 'bool' ? typeof val !== 'boolean' : typeof val !== 'string') p.at_(where, `${k}: ${Array.isArray(t) ? t.join(' | ') : t}`);
|
|
269
|
+
else out.props[k] = typeof val === 'string' ? str(val, 160) : val;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (x.order) {
|
|
273
|
+
if (name !== 'card.head') p.at_(where, 'order is only for card.head');
|
|
274
|
+
const order = arr(x.order).filter((k) => k in CARD_HEAD_ORDER);
|
|
275
|
+
if (order.length !== arr(x.order).length) p.at_(where, `order takes ${Object.keys(CARD_HEAD_ORDER).join(', ')}`);
|
|
276
|
+
out.order = order;
|
|
277
|
+
}
|
|
278
|
+
parts[name] = out;
|
|
279
|
+
}
|
|
280
|
+
const ui = obj(j.ui) || {};
|
|
281
|
+
const hide = arr(ui.hide).filter((h) => {
|
|
282
|
+
if (h in PARTS) return true;
|
|
283
|
+
p.at_('ui.hide', `${h} isn't a part`);
|
|
284
|
+
return false;
|
|
285
|
+
});
|
|
286
|
+
const tokens = (o, where) =>
|
|
287
|
+
Object.fromEntries(
|
|
288
|
+
Object.entries(obj(o) || {}).filter(([k, v]) => {
|
|
289
|
+
if (!TOKENS.includes(k)) return p.at_(where, `${k} isn't a token (${TOKENS.join(', ')})`), false;
|
|
290
|
+
const e = styleProblem('color', v);
|
|
291
|
+
return e ? (p.at_(where, `${k}: value not allowed`), false) : true;
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const tokensOut = ui.tokens ? tokens(ui.tokens, 'ui.tokens') : null;
|
|
296
|
+
const darkOut = ui.dark ? tokens(ui.dark, 'ui.dark') : null;
|
|
297
|
+
|
|
298
|
+
// prompt rules: text added to agents' system prompts, where `when` matches.
|
|
299
|
+
const prompt = arr(j.prompt).slice(0, MAX_ITEMS).map((r, i) => {
|
|
300
|
+
const x = typeof r === 'string' ? { text: r } : obj(r) || {};
|
|
301
|
+
if (!String(x.text || '').trim()) p.at_(`prompt[${i}]`, 'needs text');
|
|
302
|
+
return { text: truncate(String(x.text || '').trim(), MAX_PROMPT), ...(scopeOf(x.when) && { when: scopeOf(x.when) }) };
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const settings = {};
|
|
306
|
+
for (const [k, v] of Object.entries(obj(j.settings) || {})) {
|
|
307
|
+
const t = SETTINGS[k];
|
|
308
|
+
if (!t) p.at_(`settings.${k}`, `not a setting spells can change (${Object.keys(SETTINGS).join(', ')})`);
|
|
309
|
+
else if (Array.isArray(t) ? !t.includes(v) : typeof v !== 'string') p.at_(`settings.${k}`, Array.isArray(t) ? t.join(' | ') : 'a string');
|
|
310
|
+
else settings[k] = v;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const slash = arr(j.slash).slice(0, MAX_ITEMS).map((x0, i) => {
|
|
314
|
+
const x = obj(x0) || {};
|
|
315
|
+
const where = `slash[${i}]`;
|
|
316
|
+
const name = String(x.name || '').replace(/^\//, '').toLowerCase();
|
|
317
|
+
if (!/^[a-z][a-z0-9-]{1,20}$/.test(name)) p.at_(where, 'name: lowercase letters, digits and dashes');
|
|
318
|
+
if (['btw', 'feedback', 'spell'].includes(name)) p.at_(where, `/${name} is built in`);
|
|
319
|
+
return { name, args: str(x.args, 40), hint: str(x.hint, 80), do: checkAction(x.do, where, p, ctx) };
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const commands = arr(j.commands).slice(0, MAX_ITEMS).map((x0, i) => {
|
|
323
|
+
const x = obj(x0) || {};
|
|
324
|
+
const where = `commands[${i}]`;
|
|
325
|
+
if (!x.label) p.at_(where, 'needs a label');
|
|
326
|
+
return { id: str(x.id || `k${i}`, 40), label: str(x.label, 60), keywords: str(x.keywords, 120), icon: icon(x.icon, where), ...(x.key && { key: str(x.key, 20) }), do: checkAction(x.do, where, p, ctx) };
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const hooks = arr(j.hooks).slice(0, MAX_ITEMS).map((x0, i) => {
|
|
330
|
+
const x = obj(x0) || {};
|
|
331
|
+
const where = `hooks[${i}]`;
|
|
332
|
+
if (!(x.on in EVENTS)) p.at_(where, `on must be one of ${Object.keys(EVENTS).join(', ')}`);
|
|
333
|
+
const cond = obj(x.if) || {};
|
|
334
|
+
for (const k of Object.keys(cond)) if (!HOOK_IF.includes(k)) p.at_(where, `if.${k}: one of ${HOOK_IF.join(', ')}`);
|
|
335
|
+
return { id: str(x.id || `h${i}`, 40), on: x.on, if: cond, max: Math.min(Math.max(Number(x.max) || 1, 1), 10), do: checkAction(x.do, where, p, { ...ctx, hook: true }) };
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const recipes = arr(j.recipes).slice(0, MAX_ITEMS).map((r, i) => {
|
|
339
|
+
try {
|
|
340
|
+
const { name: rn, prompt: rp, cwd, agent, model, effort, mode } = normalizeRecipe(obj(r) || {});
|
|
341
|
+
const rid = String(r.id ?? i).replace(/[^\w-]/g, '');
|
|
342
|
+
return { id: rid.startsWith(`spell-${id}-`) ? rid : `spell-${id}-${rid}`, name: rn, prompt: rp, cwd, agent, model, effort, mode, spell: id };
|
|
343
|
+
} catch (err) {
|
|
344
|
+
p.at_(`recipes[${i}]`, err.message);
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
if (p.length) throw Object.assign(bad(`This spell has ${p.length} problem${p.length > 1 ? 's' : ''}:\n- ${p.join('\n- ')}`), { problems: [...p] });
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
spell: {
|
|
353
|
+
wispSpell: SPELL_VERSION,
|
|
354
|
+
id,
|
|
355
|
+
name,
|
|
356
|
+
version: str(j.version || '1.0.0', 20),
|
|
357
|
+
description: str(j.description, 400),
|
|
358
|
+
author: str(j.author, 60),
|
|
359
|
+
...(j.wisp && { wisp: str(j.wisp, 30) }),
|
|
360
|
+
harnesses: arr(j.harnesses).length ? arr(j.harnesses).map((h) => str(h, 20)) : ['*'],
|
|
361
|
+
...(scopeOf(j.when) && { when: scopeOf(j.when) }),
|
|
362
|
+
...(Object.keys(data).length && { data }),
|
|
363
|
+
...(buttons.length && { buttons }),
|
|
364
|
+
...(chips.length && { chips }),
|
|
365
|
+
...(panels.length && { panels }),
|
|
366
|
+
...(blocks.length && { blocks }),
|
|
367
|
+
...(Object.keys(parts).length && { parts }),
|
|
368
|
+
...((hide.length || tokensOut || darkOut) && { ui: { ...(hide.length && { hide }), ...(tokensOut && { tokens: tokensOut }), ...(darkOut && { dark: darkOut }) } }),
|
|
369
|
+
...(prompt.length && { prompt }),
|
|
370
|
+
...(Object.keys(settings).length && { settings }),
|
|
371
|
+
...(slash.length && { slash }),
|
|
372
|
+
...(commands.length && { commands }),
|
|
373
|
+
...(hooks.length && { hooks }),
|
|
374
|
+
...(recipes.length && { recipes: recipes.map(({ spell, ...r }) => r) }),
|
|
375
|
+
...(script && { script }),
|
|
376
|
+
},
|
|
377
|
+
warnings,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Where a prompt rule, block or hook applies: { project: [names|ids|paths], agent: [...], mode: [...] }.
|
|
382
|
+
function scopeOf(v) {
|
|
383
|
+
const o = obj(v);
|
|
384
|
+
if (!o) return undefined;
|
|
385
|
+
const out = {};
|
|
386
|
+
for (const k of ['project', 'agent', 'mode']) if (o[k] !== undefined) out[k] = arr(o[k]).map((x) => str(x, 200));
|
|
387
|
+
return Object.keys(out).length ? out : undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function scopeMatches(scope, { project, agent, mode, cwd } = {}) {
|
|
391
|
+
if (!scope) return true;
|
|
392
|
+
if (scope.agent && !scope.agent.includes(agent) && !scope.agent.includes('*')) return false;
|
|
393
|
+
if (scope.mode && !scope.mode.includes(mode)) return false;
|
|
394
|
+
if (scope.project && !scope.project.some((x) => x === project?.name || x === project?.id || (x.startsWith('/') && String(cwd || '').startsWith(x)))) return false;
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// What a spell does, in plain words, with the riskiest kind it reaches (for the review badges).
|
|
399
|
+
export function describeSpell(s) {
|
|
400
|
+
const lines = [];
|
|
401
|
+
let kind = 'safe';
|
|
402
|
+
const bump = (k) => RANK[k] > RANK[kind] && (kind = k);
|
|
403
|
+
const act = (a) => {
|
|
404
|
+
if (!a) return '';
|
|
405
|
+
const [k, v] = Object.entries(a)[0];
|
|
406
|
+
bump(ACTIONS[k]?.kind || 'safe');
|
|
407
|
+
const what = {
|
|
408
|
+
open: () => `opens ${v}`,
|
|
409
|
+
openAll: () => (v.data ? `opens every link in ${v.data}` : `opens the links ${v.fn}() returns`),
|
|
410
|
+
panel: () => `opens the “${(s.panels || []).find((x) => x.id === v)?.title || v}” panel`,
|
|
411
|
+
insert: () => `types “${clip(v, 50)}” into the box`,
|
|
412
|
+
copy: () => `copies “${clip(v, 40)}”`,
|
|
413
|
+
toast: () => 'shows a message',
|
|
414
|
+
setLaunch: () => `sets the prompt box to ${Object.values(v).filter(Boolean).join(' · ')}`,
|
|
415
|
+
send: () => `sends “${clip(v, 50)}” to the task`,
|
|
416
|
+
reply: () => `replies “${clip(v, 50)}” to the task`,
|
|
417
|
+
launchWith: () => `launches with ${Object.entries(v).filter(([k2, x]) => x && k2 !== 'text').map(([, x]) => x).join(' · ')}`,
|
|
418
|
+
launch: () => `starts a task: “${clip(v.prompt, 50)}”`,
|
|
419
|
+
rerun: () => `reruns the task on ${[v.agent, v.model, v.mode].filter(Boolean).join(' · ') || 'another harness'}`,
|
|
420
|
+
recipe: () => `runs the recipe ${v}`,
|
|
421
|
+
ship: () => 'opens Ship it',
|
|
422
|
+
done: () => 'marks the task done',
|
|
423
|
+
archive: () => 'archives the task',
|
|
424
|
+
notify: () => 'notifies you',
|
|
425
|
+
webhook: () => `POSTs the task to ${v}`,
|
|
426
|
+
exec: () => `runs \`${clip(v, 60)}\``,
|
|
427
|
+
}[k];
|
|
428
|
+
return what ? what() : k;
|
|
429
|
+
};
|
|
430
|
+
const WHERE = { topbar: 'A top bar button', float: 'A floating button', palette: 'A ⌘K item', 'launcher.bar': 'A prompt box button', 'launcher.chips': 'A chip under the prompt box', 'composer.bar': 'A follow-up box button', 'composer.chips': 'A quick reply above the follow-up box', 'card.head': "A chip in each card's head", 'card.hover': 'A card button (on hover)', 'card.menu': "An item in each card's menu" };
|
|
431
|
+
const cards = (w) => (w ? ` (cards ${Object.entries(w).map(([k, v]) => `${k}: ${v}`).join(', ')})` : '');
|
|
432
|
+
for (const b of s.buttons || []) lines.push(`${WHERE[b.slot] || b.slot} “${b.label || b.icon}”${cards(b.when)} that ${act(b.do)}`);
|
|
433
|
+
for (const c of s.chips || []) lines.push(`${WHERE[c.slot] || c.slot}${c.text ? ` showing “${c.text}”` : ''}${cards(c.when)}${c.do ? ` that ${act(c.do)}` : ''}`);
|
|
434
|
+
for (const x of s.panels || []) lines.push(`A ${x.as === 'card' ? `panel in each card (${x.slot})` : x.as === 'feed' ? 'panel pinned above the feed' : `${x.as}`} “${x.title || x.id}”`);
|
|
435
|
+
for (const b of s.blocks || []) lines.push(`A “${b.type}” block agents can use in their reports`);
|
|
436
|
+
for (const [name, v] of Object.entries(s.parts || {})) lines.push(`Changes ${name}${v.style ? ` (${Object.keys(v.style).join(', ')})` : ''}${v.props ? ` (${Object.entries(v.props).map(([k, x]) => `${k}: ${x}`).join(', ')})` : ''}${v.order ? ` (order: ${v.order.join(', ')})` : ''}`);
|
|
437
|
+
if (s.ui?.hide?.length) lines.push(`Hides ${s.ui.hide.join(', ')}`);
|
|
438
|
+
if (s.ui?.tokens || s.ui?.dark) lines.push(`Recolours wisp (${Object.keys({ ...s.ui.tokens, ...s.ui.dark }).join(', ')})`);
|
|
439
|
+
for (const r of s.prompt || []) lines.push(`Tells ${r.when?.agent ? r.when.agent.join('/') : 'every'} agent${r.when?.project ? ` in ${r.when.project.join(', ')}` : ''}: “${clip(r.text, 90)}”`);
|
|
440
|
+
if (s.settings) lines.push(`Sets ${Object.entries(s.settings).map(([k, v]) => `${k} = ${v}`).join(', ')} while it's on`);
|
|
441
|
+
for (const x of s.slash || []) lines.push(`/${x.name} ${x.args ? `<${x.args}> ` : ''}that ${act(x.do)}`);
|
|
442
|
+
for (const x of s.commands || []) lines.push(`⌘K “${x.label}” that ${act(x.do)}`);
|
|
443
|
+
for (const h of s.hooks || []) {
|
|
444
|
+
bump('acts');
|
|
445
|
+
lines.push(`On its own, when ${h.on.replace('.', ' ')}${Object.keys(h.if).length ? ` (${Object.entries(h.if).map(([k, v]) => `${k}: ${v}`).join(', ')})` : ''}: ${act(h.do)}, at most ${h.max}× per task`);
|
|
446
|
+
}
|
|
447
|
+
for (const r of s.recipes || []) lines.push(`A recipe “${r.name}”`);
|
|
448
|
+
for (const [k, d] of Object.entries(s.data || {})) {
|
|
449
|
+
if (d.gh) bump('reads');
|
|
450
|
+
lines.push(d.gh ? `Reads GitHub: \`gh ${d.gh.join(' ')}\`` : `Reads wisp's ${d.wisp}`);
|
|
451
|
+
}
|
|
452
|
+
return { lines, kind };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export const spellHash = (s) => createHash('sha256').update(JSON.stringify(s)).digest('hex').slice(0, 16);
|
|
456
|
+
|
|
457
|
+
// The store: ~/.wisp/spells/<id>.json files plus ~/.wisp/spells.json (on/off and where each came from).
|
|
458
|
+
export class Spells {
|
|
459
|
+
constructor(home, { safe = process.env.WISP_SAFE === '1' } = {}) {
|
|
460
|
+
this.dir = path.join(home, 'spells');
|
|
461
|
+
this.stateFile = path.join(home, 'spells.json');
|
|
462
|
+
this.safe = safe;
|
|
463
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
464
|
+
this.state = { enabled: [], sources: {}, hooks: {} };
|
|
465
|
+
try {
|
|
466
|
+
const raw = JSON.parse(fs.readFileSync(this.stateFile, 'utf8'));
|
|
467
|
+
this.state = { enabled: Array.isArray(raw.enabled) ? raw.enabled : [], sources: raw.sources || {}, hooks: raw.hooks || {} };
|
|
468
|
+
} catch {}
|
|
469
|
+
this.spells = new Map();
|
|
470
|
+
this.broken = new Map(); // id -> why the file couldn't be loaded
|
|
471
|
+
for (const f of fs.readdirSync(this.dir)) {
|
|
472
|
+
if (!f.endsWith('.json')) continue;
|
|
473
|
+
try {
|
|
474
|
+
const { spell } = normalizeSpell(JSON.parse(fs.readFileSync(path.join(this.dir, f), 'utf8')));
|
|
475
|
+
this.spells.set(spell.id, spell);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
this.broken.set(f.replace(/\.json$/, ''), clip(err.message, 400));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
this.onChange = () => {};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#saveState() {
|
|
484
|
+
writeJsonAtomic(this.stateFile, this.state, 2);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
enabled() {
|
|
488
|
+
if (this.safe) return [];
|
|
489
|
+
return this.state.enabled.map((id) => this.spells.get(id)).filter(Boolean);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
get(id) {
|
|
493
|
+
return this.spells.get(id) || null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
isOn(id) {
|
|
497
|
+
return !this.safe && this.state.enabled.includes(id);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
view(s) {
|
|
501
|
+
const d = describeSpell(s);
|
|
502
|
+
return { ...s, enabled: this.isOn(s.id), source: this.state.sources[s.id] || null, hash: spellHash(s), summary: d.lines, kind: d.kind };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
list() {
|
|
506
|
+
return {
|
|
507
|
+
safe: this.safe,
|
|
508
|
+
spells: [...this.spells.values()].map((s) => this.view(s)).sort((a, b) => a.name.localeCompare(b.name)),
|
|
509
|
+
broken: [...this.broken].map(([id, error]) => ({ id, error })),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Check another spell would fit: its block types and slash names mustn't clash with one that's on.
|
|
514
|
+
#clashes(s) {
|
|
515
|
+
const out = [];
|
|
516
|
+
for (const o of this.enabled()) {
|
|
517
|
+
if (o.id === s.id) continue;
|
|
518
|
+
for (const b of s.blocks || []) if ((o.blocks || []).some((x) => x.type === b.type)) out.push(`block type ${b.type} is already ${o.name}'s`);
|
|
519
|
+
for (const x of s.slash || []) if ((o.slash || []).some((y) => y.name === x.name)) out.push(`/${x.name} is already ${o.name}'s`);
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Save (new or a new version). It stays off unless `enable` (and a new version of one that was on stays on).
|
|
525
|
+
install(raw, { source = null, enable = false } = {}) {
|
|
526
|
+
const { spell, warnings } = normalizeSpell(raw);
|
|
527
|
+
const was = this.spells.get(spell.id);
|
|
528
|
+
writeJsonAtomic(path.join(this.dir, `${spell.id}.json`), spell, 2);
|
|
529
|
+
this.spells.set(spell.id, spell);
|
|
530
|
+
this.broken.delete(spell.id);
|
|
531
|
+
if (source) this.state.sources[spell.id] = { ...source, at: Date.now() };
|
|
532
|
+
else if (!was) this.state.sources[spell.id] = { kind: 'local', at: Date.now() };
|
|
533
|
+
if (enable) this.setEnabled(spell.id, true, { quiet: true });
|
|
534
|
+
this.#saveState();
|
|
535
|
+
this.onChange();
|
|
536
|
+
return { spell: this.view(spell), warnings, updated: !!was };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
setEnabled(id, on, { quiet = false } = {}) {
|
|
540
|
+
const s = this.spells.get(id);
|
|
541
|
+
if (!s) throw bad('no such spell', 404);
|
|
542
|
+
if (on) {
|
|
543
|
+
const c = this.#clashes(s);
|
|
544
|
+
if (c.length) throw bad(`Can't turn ${s.name} on: ${c.join('; ')}.`, 409);
|
|
545
|
+
if (!this.state.enabled.includes(id)) this.state.enabled.push(id);
|
|
546
|
+
} else this.state.enabled = this.state.enabled.filter((x) => x !== id);
|
|
547
|
+
this.#saveState();
|
|
548
|
+
if (!quiet) this.onChange();
|
|
549
|
+
return this.view(s);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
remove(id) {
|
|
553
|
+
if (!this.spells.has(id) && !this.broken.has(id)) throw bad('no such spell', 404);
|
|
554
|
+
fs.rmSync(path.join(this.dir, `${id}.json`), { force: true });
|
|
555
|
+
this.spells.delete(id);
|
|
556
|
+
this.broken.delete(id);
|
|
557
|
+
this.state.enabled = this.state.enabled.filter((x) => x !== id);
|
|
558
|
+
delete this.state.sources[id];
|
|
559
|
+
delete this.state.hooks[id];
|
|
560
|
+
this.#saveState();
|
|
561
|
+
this.onChange();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// The file to share: the spell as written, nothing about this machine.
|
|
565
|
+
exported(id) {
|
|
566
|
+
const s = this.spells.get(id);
|
|
567
|
+
if (!s) throw bad('no such spell', 404);
|
|
568
|
+
return { ...s };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// --- what the rest of wisp reads ---
|
|
572
|
+
|
|
573
|
+
// Settings spells set, and which spell set each (the last one turned on wins).
|
|
574
|
+
settings() {
|
|
575
|
+
const values = {};
|
|
576
|
+
const lockedBy = {};
|
|
577
|
+
for (const s of this.enabled()) for (const [k, v] of Object.entries(s.settings || {})) (values[k] = v), (lockedBy[k] = { id: s.id, name: s.name });
|
|
578
|
+
return { values, lockedBy };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
recipes() {
|
|
582
|
+
return this.enabled().flatMap((s) => (s.recipes || []).map((r) => ({ ...r, runs: 0, lastRunAt: null, spell: { id: s.id, name: s.name } })));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
#applies(s, session, project) {
|
|
586
|
+
if (s.harnesses && !s.harnesses.includes('*') && !s.harnesses.includes(session.agent)) return false;
|
|
587
|
+
return scopeMatches(s.when, { project, agent: session.agent, mode: session.mode, cwd: session.cwd });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Prompt rules for this session, as one section of its system prompt ('' when none).
|
|
591
|
+
promptFor(session, project) {
|
|
592
|
+
const rules = this.enabled()
|
|
593
|
+
.filter((s) => this.#applies(s, session, project))
|
|
594
|
+
.flatMap((s) => (s.prompt || []).filter((r) => scopeMatches(r.when, { project, agent: session.agent, mode: session.mode, cwd: session.cwd })).map((r) => `- ${r.text} _(spell: ${s.name})_`));
|
|
595
|
+
if (!rules.length) return '';
|
|
596
|
+
return `\n\n## Spells the user cast\n\nThe user turned these on in wisp. Follow them unless the current request says otherwise.\n\n${rules.join('\n')}\n`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Extra block types agents may use in this session's report.
|
|
600
|
+
blocksFor(session, project) {
|
|
601
|
+
return this.enabled()
|
|
602
|
+
.filter((s) => this.#applies(s, session, project))
|
|
603
|
+
.flatMap((s) => (s.blocks || []).map((b) => ({ type: b.type, doc: b.doc, spell: s.name })));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
hooks(event) {
|
|
607
|
+
return this.enabled().flatMap((s) => (s.hooks || []).filter((h) => h.on === event).map((h) => ({ spell: s, hook: h })));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// A hook's run count for one session (hooks fire at most `max` times per task).
|
|
611
|
+
hookCount(spellId, hookId, sessionId) {
|
|
612
|
+
return this.state.hooks[spellId]?.[hookId]?.[sessionId] || 0;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
countHook(spellId, hookId, sessionId) {
|
|
616
|
+
const h = ((this.state.hooks[spellId] ||= {})[hookId] ||= {});
|
|
617
|
+
h[sessionId] = (h[sessionId] || 0) + 1;
|
|
618
|
+
// Keep the newest 200 sessions per hook.
|
|
619
|
+
const keys = Object.keys(h);
|
|
620
|
+
if (keys.length > 200) for (const k of keys.slice(0, keys.length - 200)) delete h[k];
|
|
621
|
+
this.#saveState();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// What the page needs to apply every spell that's on.
|
|
625
|
+
forPage() {
|
|
626
|
+
return this.enabled().map((s) => ({ ...s, kind: describeSpell(s).kind }));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// The overlay: store.config.defaults reads spell settings first while they're on, writes go to
|
|
631
|
+
// the user's own values, and saving config.json saves only the user's (toJSON).
|
|
632
|
+
export function overlayDefaults(target, settings) {
|
|
633
|
+
return new Proxy(target, {
|
|
634
|
+
get(t, k) {
|
|
635
|
+
if (k === 'toJSON') return () => t;
|
|
636
|
+
const v = settings().values;
|
|
637
|
+
return Object.hasOwn(v, k) ? v[k] : t[k];
|
|
638
|
+
},
|
|
639
|
+
ownKeys: (t) => [...new Set([...Reflect.ownKeys(t), ...Object.keys(settings().values)])],
|
|
640
|
+
getOwnPropertyDescriptor(t, k) {
|
|
641
|
+
const v = settings().values;
|
|
642
|
+
if (Object.hasOwn(v, k)) return { value: v[k], writable: true, enumerable: true, configurable: true };
|
|
643
|
+
return Reflect.getOwnPropertyDescriptor(t, k);
|
|
644
|
+
},
|
|
645
|
+
has: (t, k) => Object.hasOwn(settings().values, k) || k in t,
|
|
646
|
+
});
|
|
647
|
+
}
|