@chatpanel/events 0.14.0 → 0.16.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/capability.js +4 -0
- package/index.js +23 -0
- package/memory.js +638 -0
- package/package.json +13 -3
- package/schedule.js +374 -0
- package/scopes.js +1 -1
- package/view.js +88 -0
- package/voice-intents.js +613 -0
- package/widget.js +153 -0
package/widget.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// WIDGETS — small apps the user asks for and the model builds, which then live in THEIR
|
|
2
|
+
// ChatPanel. A timer, a calculator, a sticky note, a unit converter, a habit tracker: things
|
|
3
|
+
// nobody should have to file a feature request for. Two people's ChatPanel can differ
|
|
4
|
+
// entirely, without either of them writing code and without us shipping a release.
|
|
5
|
+
//
|
|
6
|
+
// A widget is NOT a capability. A capability is reviewed and approved before it runs, so it
|
|
7
|
+
// can be trusted to act. A widget is written by a model on a user's whim, so it is treated as
|
|
8
|
+
// exactly what it is — untrusted code — and given the smallest surface that still makes it
|
|
9
|
+
// useful:
|
|
10
|
+
//
|
|
11
|
+
// • its own state, and nothing else's. `state` is a private object keyed by widget id. A
|
|
12
|
+
// timer remembers where it got to; a sticky note remembers its text. No widget can read
|
|
13
|
+
// another's, and none can read the user's notes, meetings or chats.
|
|
14
|
+
// • no network, no chrome.*, no DOM outside its sandbox — enforced by the host, not by
|
|
15
|
+
// good behaviour here.
|
|
16
|
+
//
|
|
17
|
+
// Anything beyond that is a GRANT: a widget may REQUEST capabilities in its manifest, and
|
|
18
|
+
// those do nothing until the user approves them. Requesting is not receiving — `grants` is
|
|
19
|
+
// stored separately from the manifest precisely so a widget cannot edit its own permissions
|
|
20
|
+
// by rewriting its own code.
|
|
21
|
+
//
|
|
22
|
+
// Pure and host-free: no DOM, no storage, no postMessage. The client owns the transport and
|
|
23
|
+
// the persistence; this owns what is legal.
|
|
24
|
+
|
|
25
|
+
import { EventError } from './event.js';
|
|
26
|
+
|
|
27
|
+
const str = (v) => typeof v === 'string' && v.length > 0;
|
|
28
|
+
const MAX_HTML = 512 * 1024; // a small app, not a bundled framework
|
|
29
|
+
const MAX_STATE = 256 * 1024; // a note, a lap list — not a database
|
|
30
|
+
|
|
31
|
+
export const WIDGET_SURFACES = Object.freeze(['panel', 'chat']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate a widget MANIFEST — everything a user sees before deciding to keep it.
|
|
35
|
+
*/
|
|
36
|
+
export function validateWidget(w) {
|
|
37
|
+
if (!w || typeof w !== 'object') throw new EventError('SHAPE', 'widget must be an object');
|
|
38
|
+
if (!str(w.id)) throw new EventError('SHAPE', 'widget.id required');
|
|
39
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(w.id)) {
|
|
40
|
+
throw new EventError('SHAPE', 'widget.id must be lowercase [a-z0-9_-], max 64 chars');
|
|
41
|
+
}
|
|
42
|
+
if (!str(w.name)) throw new EventError('SHAPE', 'widget.name required');
|
|
43
|
+
if (!str(w.html)) throw new EventError('SHAPE', 'widget.html required');
|
|
44
|
+
if (w.html.length > MAX_HTML) throw new EventError('SHAPE', `widget.html exceeds ${MAX_HTML} bytes`);
|
|
45
|
+
// An icon NAME (the client maps it to its own icon set), so a pinned widget is
|
|
46
|
+
// recognisable at a glance rather than being one of five identical marks.
|
|
47
|
+
if (w.icon != null && !(str(w.icon) && /^[a-z][a-z0-9-]{0,31}$/.test(w.icon))) {
|
|
48
|
+
throw new EventError('SHAPE', 'widget.icon must be an icon name like "timer"');
|
|
49
|
+
}
|
|
50
|
+
if (w.surface != null && !WIDGET_SURFACES.includes(w.surface)) {
|
|
51
|
+
throw new EventError('SHAPE', `widget.surface must be one of ${WIDGET_SURFACES}`);
|
|
52
|
+
}
|
|
53
|
+
if (w.height != null && !(Number.isInteger(w.height) && w.height > 0 && w.height <= 2000)) {
|
|
54
|
+
throw new EventError('SHAPE', 'widget.height must be a positive integer <= 2000');
|
|
55
|
+
}
|
|
56
|
+
// Requesting is not receiving. This only records what the widget WANTS; the grant lives
|
|
57
|
+
// outside the manifest so rewriting the widget can never widen its own permissions.
|
|
58
|
+
if (w.requests != null && !(Array.isArray(w.requests) && w.requests.every(str))) {
|
|
59
|
+
throw new EventError('SHAPE', 'widget.requests must be string[] (capability ids it asks for)');
|
|
60
|
+
}
|
|
61
|
+
return w;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validate a message a widget sent. Untrusted input: nothing is believed, and the widget's
|
|
66
|
+
* own id is supplied by the HOST (which knows which frame sent it), never read from the
|
|
67
|
+
* message — otherwise a widget could name someone else's id and read their state.
|
|
68
|
+
*/
|
|
69
|
+
export function validateWidgetMessage(msg, { widgetId, grants = [] } = {}) {
|
|
70
|
+
if (!str(widgetId)) throw new EventError('SHAPE', 'host must supply the sending widget id');
|
|
71
|
+
if (!msg || typeof msg !== 'object') throw new EventError('SHAPE', 'widget message must be an object');
|
|
72
|
+
if (!str(msg.callId)) throw new EventError('SHAPE', 'widget message needs a callId');
|
|
73
|
+
|
|
74
|
+
switch (msg.op) {
|
|
75
|
+
case 'state.get':
|
|
76
|
+
return { op: 'state.get', widgetId, callId: msg.callId };
|
|
77
|
+
|
|
78
|
+
case 'state.set': {
|
|
79
|
+
if (msg.state === undefined) throw new EventError('SHAPE', 'state.set needs state');
|
|
80
|
+
let size = 0;
|
|
81
|
+
try { size = JSON.stringify(msg.state ?? null).length; }
|
|
82
|
+
catch { throw new EventError('SHAPE', 'widget state must be JSON-serialisable'); }
|
|
83
|
+
if (size > MAX_STATE) throw new EventError('SHAPE', `widget state exceeds ${MAX_STATE} bytes`);
|
|
84
|
+
return { op: 'state.set', widgetId, callId: msg.callId, state: msg.state };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
case 'invoke': {
|
|
88
|
+
if (!str(msg.capability)) throw new EventError('SHAPE', 'invoke must name a capability');
|
|
89
|
+
// THE REFUSAL THAT MATTERS. A widget gets what the user granted it and nothing else —
|
|
90
|
+
// checked here, before any kernel guard has to have an opinion, and against the stored
|
|
91
|
+
// grants rather than against anything the widget or its manifest claims.
|
|
92
|
+
if (!grants.includes(msg.capability)) {
|
|
93
|
+
throw new EventError('DENIED',
|
|
94
|
+
`widget "${widgetId}" has no grant for "${msg.capability}" — the user must approve it first`);
|
|
95
|
+
}
|
|
96
|
+
if (msg.args != null && (typeof msg.args !== 'object' || Array.isArray(msg.args))) {
|
|
97
|
+
throw new EventError('SHAPE', 'invoke args must be an object');
|
|
98
|
+
}
|
|
99
|
+
return { op: 'invoke', widgetId, callId: msg.callId, capability: msg.capability, args: msg.args || {} };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
default:
|
|
103
|
+
throw new EventError('SHAPE', `unknown widget op "${msg.op}"`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The permissions a widget actually holds: the intersection of what it asked for and what the
|
|
109
|
+
* user approved. Asking for more later cannot grant it — a request that was never approved
|
|
110
|
+
* stays absent, so an updated widget re-asking silently gains nothing.
|
|
111
|
+
*/
|
|
112
|
+
export function effectiveGrants(widget, approved = []) {
|
|
113
|
+
const asked = new Set(widget?.requests || []);
|
|
114
|
+
return (approved || []).filter((id) => asked.has(id));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Pick an icon for a widget from what it is called. A pinned widget sits in a narrow strip
|
|
118
|
+
// next to the others, so five identical marks are worse than no icon at all — the point of
|
|
119
|
+
// pinning is to find the thing without reading.
|
|
120
|
+
//
|
|
121
|
+
// Returns an icon NAME, not a glyph: the client owns how it is drawn (the extension resolves
|
|
122
|
+
// these through its vendored set, a mobile client through its own), and a name survives that
|
|
123
|
+
// mapping in a way an emoji does not. Every name here exists in the extension's icon set.
|
|
124
|
+
const ICON_WORDS = [
|
|
125
|
+
[/\b(timers?|pomodoro|stopwatch|countdowns?|intervals?)\b/i, 'timer'],
|
|
126
|
+
[/\b(calc|calculators?|math|arithmetic|tip)\b/i, 'hash'],
|
|
127
|
+
[/\b(notes?|sticky|scratch|memos?|journals?)\b/i, 'notebook-pen'],
|
|
128
|
+
[/\b(todos?|tasks?|checklists?|habits?|trackers?)\b/i, 'list-checks'],
|
|
129
|
+
[/\b(charts?|graphs?|stats?|metrics?|dashboards?)\b/i, 'bar-chart-3'],
|
|
130
|
+
[/\b(convert|converters?|units?|currency|exchange|weights?)\b/i, 'scale'],
|
|
131
|
+
[/\b(calendars?|schedules?|agendas?|dates?)\b/i, 'calendar'],
|
|
132
|
+
[/\b(clocks?|time|timezones?|zones?)\b/i, 'clock'],
|
|
133
|
+
[/\b(goals?|targets?|focus|okrs?)\b/i, 'target'],
|
|
134
|
+
[/\b(ideas?|brainstorm|prompts?)\b/i, 'lightbulb'],
|
|
135
|
+
[/\b(dice|random|rolls?|roller|coins?|shuffle)\b/i, 'zap'],
|
|
136
|
+
[/\b(search|find|lookup)\b/i, 'search'],
|
|
137
|
+
[/\b(web|urls?|links?|browser)\b/i, 'globe'],
|
|
138
|
+
[/\b(mood|mind|memory|brain)\b/i, 'brain'],
|
|
139
|
+
[/\b(password|secrets?|vault|lock)\b/i, 'lock'],
|
|
140
|
+
[/\b(quotes?|sayings?)\b/i, 'quote'],
|
|
141
|
+
[/\b(meetings?|standups?|people|team)\b/i, 'users'],
|
|
142
|
+
[/\b(music|player|sound|audio)\b/i, 'play'],
|
|
143
|
+
[/\b(photos?|images?|gallery)\b/i, 'image'],
|
|
144
|
+
[/\b(files?|documents?|docs?)\b/i, 'file-text'],
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
/** The widget's own icon if it declared one, else one derived from its name. */
|
|
148
|
+
export function widgetIcon(widget) {
|
|
149
|
+
if (widget?.icon) return widget.icon;
|
|
150
|
+
const name = String(widget?.name || '');
|
|
151
|
+
for (const [re, iconName] of ICON_WORDS) if (re.test(name)) return iconName;
|
|
152
|
+
return 'app-window'; // generic, but never the mark the shelf itself uses
|
|
153
|
+
}
|