@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
"./markdown-authoring.js": "./markdown-authoring.js",
|
|
19
19
|
"./mcp-errors.js": "./mcp-errors.js",
|
|
20
20
|
"./meeting-analyzers.js": "./meeting-analyzers.js",
|
|
21
|
+
"./memory.js": "./memory.js",
|
|
21
22
|
"./order.js": "./order.js",
|
|
22
23
|
"./ref.js": "./ref.js",
|
|
23
24
|
"./registry.js": "./registry.js",
|
|
24
25
|
"./route-graph.js": "./route-graph.js",
|
|
25
26
|
"./router.js": "./router.js",
|
|
26
27
|
"./rules.js": "./rules.js",
|
|
28
|
+
"./schedule.js": "./schedule.js",
|
|
27
29
|
"./scopes.js": "./scopes.js",
|
|
28
30
|
"./search-engines.js": "./search-engines.js",
|
|
29
31
|
"./skill-manifest.js": "./skill-manifest.js",
|
|
@@ -38,9 +40,12 @@
|
|
|
38
40
|
"./tool-need.js": "./tool-need.js",
|
|
39
41
|
"./trajectory.js": "./trajectory.js",
|
|
40
42
|
"./upcast.js": "./upcast.js",
|
|
43
|
+
"./voice-intents.js": "./voice-intents.js",
|
|
41
44
|
"./observability.js": "./observability.js",
|
|
42
45
|
"./flowchart.js": "./flowchart.js",
|
|
43
|
-
"./rrf.js": "./rrf.js"
|
|
46
|
+
"./rrf.js": "./rrf.js",
|
|
47
|
+
"./view.js": "./view.js",
|
|
48
|
+
"./widget.js": "./widget.js"
|
|
44
49
|
},
|
|
45
50
|
"files": [
|
|
46
51
|
"LICENSE",
|
|
@@ -59,6 +64,7 @@
|
|
|
59
64
|
"markdown-authoring.js",
|
|
60
65
|
"mcp-errors.js",
|
|
61
66
|
"meeting-analyzers.js",
|
|
67
|
+
"memory.js",
|
|
62
68
|
"observability.js",
|
|
63
69
|
"order.js",
|
|
64
70
|
"ref.js",
|
|
@@ -67,6 +73,7 @@
|
|
|
67
73
|
"router.js",
|
|
68
74
|
"rrf.js",
|
|
69
75
|
"rules.js",
|
|
76
|
+
"schedule.js",
|
|
70
77
|
"scopes.js",
|
|
71
78
|
"search-engines.js",
|
|
72
79
|
"skill-manifest.js",
|
|
@@ -80,7 +87,10 @@
|
|
|
80
87
|
"tool-groups.js",
|
|
81
88
|
"tool-need.js",
|
|
82
89
|
"trajectory.js",
|
|
83
|
-
"upcast.js"
|
|
90
|
+
"upcast.js",
|
|
91
|
+
"voice-intents.js",
|
|
92
|
+
"view.js",
|
|
93
|
+
"widget.js"
|
|
84
94
|
],
|
|
85
95
|
"scripts": {
|
|
86
96
|
"test": "node --test tests/*.test.js"
|
package/schedule.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// Jobs — the thing that runs when nobody asked it to.
|
|
2
|
+
//
|
|
3
|
+
// Every turn ChatPanel has ever run began with a person pressing send. A job does not, and
|
|
4
|
+
// that single difference is what this contract is mostly about: consent, cost, dedup and a
|
|
5
|
+
// findable result all have to be settled BEFORE the run, because there is nobody sitting
|
|
6
|
+
// there to judge the outcome.
|
|
7
|
+
//
|
|
8
|
+
// Three layers, and the split is the whole point (see docs/feature-f5-scheduler.md):
|
|
9
|
+
//
|
|
10
|
+
// • schedule maths — "when does this next fire", "what did we miss while the laptop was
|
|
11
|
+
// asleep". Pure input → output, and wrong in a subtly different way in every
|
|
12
|
+
// reimplementation, so it is written once, here.
|
|
13
|
+
// • the job model, admission and dedup — policy, also here.
|
|
14
|
+
// • WAKING UP — `chrome.alarms`, `WorkManager`, `BGTaskScheduler`. The only platform-bound
|
|
15
|
+
// part, injected by the client, never imported.
|
|
16
|
+
//
|
|
17
|
+
// A trigger says WHEN. It never says what, and it cannot widen what a job may do: a phrase
|
|
18
|
+
// spoken in a meeting can start a job the user already created and approved, and can do
|
|
19
|
+
// nothing else. That is deliberate — the transcript is written by everyone in the room.
|
|
20
|
+
//
|
|
21
|
+
// WALL CLOCK, NOT INTERVALS. "Every day at 8am" is stored as an hour and a minute, not as
|
|
22
|
+
// 86_400_000 milliseconds, because the second one drifts by an hour twice a year and nobody
|
|
23
|
+
// can explain why the brief started arriving at 7. `now` is injected for the same reason it
|
|
24
|
+
// is everywhere else in this package: so a Wednesday can be tested on a Tuesday.
|
|
25
|
+
|
|
26
|
+
export class ScheduleError extends Error {
|
|
27
|
+
constructor(code, message) { super(message); this.name = 'ScheduleError'; this.code = code; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const SCHEDULE_KINDS = Object.freeze(['once', 'interval', 'daily', 'weekly']);
|
|
31
|
+
export const TRIGGER_KINDS = Object.freeze(['timer', 'meeting', 'voice', 'data']);
|
|
32
|
+
/** What a job does when it fires. `skill` is the headline: the instruction IS a skill. */
|
|
33
|
+
export const JOB_ACTIONS = Object.freeze(['skill', 'prompt', 'monitor', 'notify']);
|
|
34
|
+
/** What to do about occurrences that passed while nothing was running. */
|
|
35
|
+
export const MISSED_POLICIES = Object.freeze(['skip', 'runOnce', 'runAll']);
|
|
36
|
+
|
|
37
|
+
const MAX_CATCH_UP = 20; // a fortnight asleep must not queue a hundred model calls
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Schedule maths
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function atLocal(base, { days = 0, hour, minute = 0 }) {
|
|
44
|
+
const d = new Date(base);
|
|
45
|
+
d.setDate(d.getDate() + days);
|
|
46
|
+
d.setHours(hour, minute, 0, 0);
|
|
47
|
+
return d.getTime();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isWeekend = (ts) => { const d = new Date(ts).getDay(); return d === 0 || d === 6; };
|
|
51
|
+
|
|
52
|
+
export function validateSchedule(s) {
|
|
53
|
+
if (!s || typeof s !== 'object') throw new ScheduleError('BAD_SCHEDULE', 'schedule must be an object');
|
|
54
|
+
if (!SCHEDULE_KINDS.includes(s.kind)) throw new ScheduleError('BAD_SCHEDULE', `unknown schedule kind '${s.kind}'`);
|
|
55
|
+
if (s.kind === 'once' && !(s.at > 0)) throw new ScheduleError('BAD_SCHEDULE', 'once needs `at`');
|
|
56
|
+
if (s.kind === 'interval' && !(s.everyMs >= 60_000)) {
|
|
57
|
+
// A minute is the floor every platform's scheduler shares (chrome.alarms refuses less).
|
|
58
|
+
// Accepting 5s here would produce a job that silently fires on somebody else's cadence.
|
|
59
|
+
throw new ScheduleError('BAD_SCHEDULE', 'interval needs everyMs >= 60000');
|
|
60
|
+
}
|
|
61
|
+
if (s.kind === 'daily' || s.kind === 'weekly') {
|
|
62
|
+
if (!Number.isInteger(s.hour) || s.hour < 0 || s.hour > 23) throw new ScheduleError('BAD_SCHEDULE', 'hour must be 0-23');
|
|
63
|
+
const m = s.minute ?? 0;
|
|
64
|
+
if (!Number.isInteger(m) || m < 0 || m > 59) throw new ScheduleError('BAD_SCHEDULE', 'minute must be 0-59');
|
|
65
|
+
}
|
|
66
|
+
if (s.kind === 'weekly' && (!Number.isInteger(s.weekday) || s.weekday < 0 || s.weekday > 6)) {
|
|
67
|
+
throw new ScheduleError('BAD_SCHEDULE', 'weekly needs weekday 0-6 (Sunday = 0)');
|
|
68
|
+
}
|
|
69
|
+
return s;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The first firing STRICTLY after `from`. Null when a one-shot is already spent.
|
|
74
|
+
*
|
|
75
|
+
* Strictly after, so feeding a fire time back in advances instead of returning the same
|
|
76
|
+
* occurrence forever — the loop that ran a job and then asked "what's next?" would otherwise
|
|
77
|
+
* never stop.
|
|
78
|
+
*/
|
|
79
|
+
export function nextFireAt(schedule, from) {
|
|
80
|
+
const s = validateSchedule(schedule);
|
|
81
|
+
const minute = s.minute ?? 0;
|
|
82
|
+
switch (s.kind) {
|
|
83
|
+
case 'once':
|
|
84
|
+
return s.at > from ? s.at : null;
|
|
85
|
+
case 'interval': {
|
|
86
|
+
// Anchored so a job restored from storage keeps its original phase instead of drifting
|
|
87
|
+
// a little later every time the extension restarts.
|
|
88
|
+
const anchor = s.anchor ?? from;
|
|
89
|
+
if (anchor > from) return anchor;
|
|
90
|
+
const steps = Math.floor((from - anchor) / s.everyMs) + 1;
|
|
91
|
+
return anchor + steps * s.everyMs;
|
|
92
|
+
}
|
|
93
|
+
case 'daily': {
|
|
94
|
+
let at = atLocal(from, { hour: s.hour, minute });
|
|
95
|
+
if (at <= from) at = atLocal(from, { days: 1, hour: s.hour, minute });
|
|
96
|
+
if (s.weekdaysOnly) for (let i = 0; i < 7 && isWeekend(at); i++) at = atLocal(at, { days: 1, hour: s.hour, minute });
|
|
97
|
+
return at;
|
|
98
|
+
}
|
|
99
|
+
case 'weekly': {
|
|
100
|
+
const day = new Date(from).getDay();
|
|
101
|
+
let delta = (s.weekday - day + 7) % 7;
|
|
102
|
+
if (delta === 0 && atLocal(from, { hour: s.hour, minute }) <= from) delta = 7;
|
|
103
|
+
return atLocal(from, { days: delta, hour: s.hour, minute });
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Every firing in (from, to], oldest first. Capped: a long sleep is not a queue of work. */
|
|
111
|
+
export function occurrencesBetween(schedule, from, to, max = MAX_CATCH_UP) {
|
|
112
|
+
const out = [];
|
|
113
|
+
let cursor = from;
|
|
114
|
+
while (out.length < max) {
|
|
115
|
+
const at = nextFireAt(schedule, cursor);
|
|
116
|
+
if (at === null || at > to) break;
|
|
117
|
+
out.push(at);
|
|
118
|
+
cursor = at;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Triggers — declarations. A trigger says WHEN, never what.
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param matches (event, params, ctx) => match | null. Pure and synchronous, like rules.js:
|
|
129
|
+
* "did this match" must be answerable without side effects or a network.
|
|
130
|
+
* @param watches the event types it can possibly match, so a busy bus is cheap.
|
|
131
|
+
*/
|
|
132
|
+
export function defineTrigger({
|
|
133
|
+
id, label, kind, watches = [], description = '', classUsed = 'R', matches = null, params = {},
|
|
134
|
+
}) {
|
|
135
|
+
if (!id) throw new ScheduleError('BAD_TRIGGER', 'trigger.id required');
|
|
136
|
+
if (!TRIGGER_KINDS.includes(kind)) throw new ScheduleError('BAD_TRIGGER', `trigger '${id}': unknown kind '${kind}'`);
|
|
137
|
+
if (kind !== 'timer' && typeof matches !== 'function') {
|
|
138
|
+
throw new ScheduleError('BAD_TRIGGER', `trigger '${id}': an event trigger needs matches()`);
|
|
139
|
+
}
|
|
140
|
+
return Object.freeze({ id, label: label || id, kind, watches: Object.freeze([...watches]), description, classUsed, matches, params: Object.freeze({ ...params }) });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createTriggerRegistry(triggers = []) {
|
|
144
|
+
const list = [...triggers];
|
|
145
|
+
return {
|
|
146
|
+
add(t) { list.push(t); return () => { const i = list.indexOf(t); if (i >= 0) list.splice(i, 1); }; },
|
|
147
|
+
list: () => [...list],
|
|
148
|
+
get: (id) => list.find((t) => t.id === id) || null,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const words = (s) => String(s || '').toLowerCase().match(/[\p{L}\p{N}']+/gu) || [];
|
|
153
|
+
const norm = (s) => String(s || '').trim().toLowerCase();
|
|
154
|
+
|
|
155
|
+
// Whose speech a meeting trigger cares about. The default is 'anyone' because a phrase
|
|
156
|
+
// trigger is usually watching for what OTHERS say — unlike a spoken command, which is only
|
|
157
|
+
// ever the owner's (see voice-intents.js).
|
|
158
|
+
function speakerAllowed(want, speaker, ctx) {
|
|
159
|
+
if (want === 'me') return !!ctx?.isSelf?.(speaker);
|
|
160
|
+
if (want === 'others') return !ctx?.isSelf?.(speaker);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const timerTrigger = defineTrigger({
|
|
165
|
+
id: 'timer:schedule',
|
|
166
|
+
label: 'On a schedule',
|
|
167
|
+
kind: 'timer',
|
|
168
|
+
description: 'Once, on an interval, daily, or on a weekday — "every weekday at 8am".',
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
export const meetingStartedTrigger = defineTrigger({
|
|
172
|
+
id: 'meeting:started',
|
|
173
|
+
label: 'When a meeting starts',
|
|
174
|
+
kind: 'meeting',
|
|
175
|
+
watches: ['meeting.started'],
|
|
176
|
+
matches: (event, params = {}) => {
|
|
177
|
+
if (params.platform && norm(event.platform) !== norm(params.platform)) return null;
|
|
178
|
+
if (params.titleIncludes && !norm(event.title).includes(norm(params.titleIncludes))) return null;
|
|
179
|
+
return { why: `meeting started: ${event.title || event.meetingId}` };
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
export const meetingEndedTrigger = defineTrigger({
|
|
184
|
+
id: 'meeting:ended',
|
|
185
|
+
label: 'When a meeting ends',
|
|
186
|
+
kind: 'meeting',
|
|
187
|
+
watches: ['meeting.ended'],
|
|
188
|
+
matches: (event) => ({ why: `meeting ended: ${event.title || event.meetingId}` }),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
export const personJoinedTrigger = defineTrigger({
|
|
192
|
+
id: 'meeting:person-joined',
|
|
193
|
+
label: 'When someone joins',
|
|
194
|
+
kind: 'meeting',
|
|
195
|
+
watches: ['meeting.person-joined'],
|
|
196
|
+
// No names means anyone — "tell me when the call fills up" is as valid as "tell me when
|
|
197
|
+
// Alex joins", and an empty list that matched nothing would look like a broken job.
|
|
198
|
+
matches: (event, params = {}) => {
|
|
199
|
+
const want = (params.names || []).map(norm).filter(Boolean);
|
|
200
|
+
const joined = (event.people || []).filter((p) => !want.length || want.some((n) => norm(p) === n || norm(p).startsWith(`${n} `)));
|
|
201
|
+
return joined.length ? { why: `joined: ${joined.join(', ')}`, people: joined } : null;
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
export const phraseTrigger = defineTrigger({
|
|
206
|
+
id: 'meeting:phrase',
|
|
207
|
+
label: 'When a phrase is said',
|
|
208
|
+
kind: 'meeting',
|
|
209
|
+
watches: ['meeting.transcript.delta'],
|
|
210
|
+
matches: (event, params = {}, ctx = {}) => {
|
|
211
|
+
const any = (params.any || []).map(norm).filter(Boolean);
|
|
212
|
+
if (!any.length) return null; // a phrase trigger with no phrase would fire on every word
|
|
213
|
+
for (const seg of event.segments || []) {
|
|
214
|
+
if (!speakerAllowed(params.speaker, seg.speaker, ctx)) continue;
|
|
215
|
+
const text = norm(seg.text);
|
|
216
|
+
const hit = any.find((p) => text.includes(p));
|
|
217
|
+
if (hit) return { why: `“${hit}” said by ${seg.speaker || 'someone'}`, segment: seg, phrase: hit };
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
export const topicTrigger = defineTrigger({
|
|
224
|
+
id: 'meeting:topic',
|
|
225
|
+
label: 'When someone talks about something',
|
|
226
|
+
kind: 'meeting',
|
|
227
|
+
watches: ['meeting.transcript.delta'],
|
|
228
|
+
// Looser than a phrase on purpose: "says something about pricing" should not require the
|
|
229
|
+
// word "pricing" in the exact shape the job author typed. Term overlap over the window is
|
|
230
|
+
// deterministic, explainable, and free — a model would be all three of the opposite.
|
|
231
|
+
matches: (event, params = {}, ctx = {}) => {
|
|
232
|
+
const terms = (params.terms || []).map(norm).filter(Boolean);
|
|
233
|
+
if (!terms.length) return null;
|
|
234
|
+
const need = Math.max(1, Math.min(params.minHits || 1, terms.length));
|
|
235
|
+
const window = (event.segments || []).filter((s) => speakerAllowed(params.speaker, s.speaker, ctx));
|
|
236
|
+
const bag = new Set(window.flatMap((s) => words(s.text)));
|
|
237
|
+
const hits = terms.filter((t) => t.split(/\s+/).every((w) => bag.has(w)));
|
|
238
|
+
return hits.length >= need ? { why: `talking about ${hits.join(', ')}`, terms: hits } : null;
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// A question mark is the cheap half; the interrogative openers are what catch speech-to-text
|
|
243
|
+
// output, which frequently drops the punctuation entirely.
|
|
244
|
+
const QUESTION = /^(who|what|when|where|why|how|which|whose|can|could|would|should|shall|do|does|did|is|are|was|were|will|have|has|any(one|body)|is there|are there)\b/i;
|
|
245
|
+
|
|
246
|
+
export const questionTrigger = defineTrigger({
|
|
247
|
+
id: 'meeting:question',
|
|
248
|
+
label: 'When a question is asked',
|
|
249
|
+
kind: 'meeting',
|
|
250
|
+
watches: ['meeting.transcript.delta'],
|
|
251
|
+
matches: (event, params = {}, ctx = {}) => {
|
|
252
|
+
for (const seg of event.segments || []) {
|
|
253
|
+
if (!speakerAllowed(params.speaker || 'others', seg.speaker, ctx)) continue;
|
|
254
|
+
const text = String(seg.text || '').trim();
|
|
255
|
+
if (text.length < 8) continue; // "what?" is not a question worth waking a model for
|
|
256
|
+
if (text.includes('?') || QUESTION.test(text)) return { why: `question from ${seg.speaker || 'someone'}`, segment: seg };
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
export const voiceCommandTrigger = defineTrigger({
|
|
263
|
+
id: 'voice:command',
|
|
264
|
+
label: 'When you say the wake word',
|
|
265
|
+
kind: 'voice',
|
|
266
|
+
watches: ['voice.command'],
|
|
267
|
+
matches: (event, params = {}) => {
|
|
268
|
+
const want = params.intents || [];
|
|
269
|
+
if (!event.command?.allowed) return null; // authority is settled upstream; never widened here
|
|
270
|
+
if (want.length && !want.includes(event.command.intent)) return null;
|
|
271
|
+
return { why: `you said “${event.command.command}”`, command: event.command };
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
export const BUILTIN_TRIGGERS = Object.freeze([
|
|
276
|
+
timerTrigger, meetingStartedTrigger, meetingEndedTrigger, personJoinedTrigger,
|
|
277
|
+
phraseTrigger, topicTrigger, questionTrigger, voiceCommandTrigger,
|
|
278
|
+
]);
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Jobs
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* @param action { kind: 'skill', skillId } — the headline case: the instruction IS a skill,
|
|
286
|
+
* so "every morning, do my daily brief" is a job whose action names the skill
|
|
287
|
+
* the user already wrote. Also 'prompt' (raw text), 'monitor', 'notify'.
|
|
288
|
+
* @param limits { maxPerDay } — a job that fails must back off, not retry in a tight loop.
|
|
289
|
+
* @param onMissed what to do about occurrences that passed while nothing was awake. Stated,
|
|
290
|
+
* because silently running eleven catch-up briefs is as wrong as silently
|
|
291
|
+
* running none, and the difference is money.
|
|
292
|
+
*/
|
|
293
|
+
export function defineJob({
|
|
294
|
+
id, name, trigger, schedule = null, params = {}, action,
|
|
295
|
+
enabled = true, onMissed = 'runOnce', limits = {}, approval = null, createdAt = 0,
|
|
296
|
+
}) {
|
|
297
|
+
if (!id) throw new ScheduleError('BAD_JOB', 'job.id required');
|
|
298
|
+
if (!name) throw new ScheduleError('BAD_JOB', `job '${id}': name required`);
|
|
299
|
+
if (!trigger) throw new ScheduleError('BAD_JOB', `job '${id}': trigger required`);
|
|
300
|
+
if (!action || !JOB_ACTIONS.includes(action.kind)) {
|
|
301
|
+
throw new ScheduleError('BAD_JOB', `job '${id}': action.kind must be one of ${JOB_ACTIONS}`);
|
|
302
|
+
}
|
|
303
|
+
if (action.kind === 'skill' && !action.skillId) throw new ScheduleError('BAD_JOB', `job '${id}': skill action needs skillId`);
|
|
304
|
+
if (!MISSED_POLICIES.includes(onMissed)) throw new ScheduleError('BAD_JOB', `job '${id}': unknown onMissed '${onMissed}'`);
|
|
305
|
+
if (trigger === timerTrigger.id || schedule) validateSchedule(schedule);
|
|
306
|
+
return {
|
|
307
|
+
id, name, trigger, schedule, params: { ...params }, action: { ...action },
|
|
308
|
+
enabled: !!enabled, onMissed, limits: { ...limits }, approval, createdAt: createdAt || 0,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Dedup is on the SCHEDULED time, never the fired time — a wake-up at 09:04 for the 09:00
|
|
314
|
+
* slot is the 09:00 run, and a second wake-up for that slot is a no-op. Alarms are
|
|
315
|
+
* approximate and devices sleep; without this, "approximately 9" means "twice".
|
|
316
|
+
*/
|
|
317
|
+
export function occurrenceKey(jobId, at) { return `${jobId}@${at}`; }
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Which timer jobs are due, and which of those are catch-up rather than on-time.
|
|
321
|
+
*
|
|
322
|
+
* @param lastRun { [jobId]: ts } — the last OCCURRENCE run, not the last wake-up.
|
|
323
|
+
*/
|
|
324
|
+
export function dueJobs(jobs, { now, lastRun = {}, admit = null, max = MAX_CATCH_UP } = {}) {
|
|
325
|
+
const out = [];
|
|
326
|
+
for (const job of jobs || []) {
|
|
327
|
+
if (!job.enabled) continue;
|
|
328
|
+
if (job.trigger !== timerTrigger.id || !job.schedule) continue;
|
|
329
|
+
if (admit && !admit(job)) continue;
|
|
330
|
+
const since = lastRun[job.id] || job.createdAt || 0;
|
|
331
|
+
if (!since) continue; // a job with no anchor cannot know what it missed
|
|
332
|
+
const missed = occurrencesBetween(job.schedule, since, now, max);
|
|
333
|
+
if (!missed.length) continue;
|
|
334
|
+
const runs = job.onMissed === 'runAll' ? missed
|
|
335
|
+
: job.onMissed === 'runOnce' ? [missed[missed.length - 1]]
|
|
336
|
+
: [];
|
|
337
|
+
for (const at of runs) out.push({ job, at, key: occurrenceKey(job.id, at), late: now - at > 60_000, missedCount: missed.length });
|
|
338
|
+
// 'skip' still reports so the caller can advance its watermark without running anything.
|
|
339
|
+
if (!runs.length) out.push({ job, at: missed[missed.length - 1], key: occurrenceKey(job.id, missed[missed.length - 1]), skipped: true, missedCount: missed.length });
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The soonest any timer job wants to be woken, so a client arms ONE platform alarm. */
|
|
345
|
+
export function nextWakeAt(jobs, { now, lastRun = {} } = {}) {
|
|
346
|
+
let soonest = null;
|
|
347
|
+
for (const job of jobs || []) {
|
|
348
|
+
if (!job.enabled || job.trigger !== timerTrigger.id || !job.schedule) continue;
|
|
349
|
+
const at = nextFireAt(job.schedule, Math.max(now, lastRun[job.id] || 0));
|
|
350
|
+
if (at !== null && (soonest === null || at < soonest)) soonest = at;
|
|
351
|
+
}
|
|
352
|
+
return soonest;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Which jobs an event fires. Returns matches; running them is the host's business, because
|
|
357
|
+
* only the host knows what a skill costs and whether the user approved it.
|
|
358
|
+
*/
|
|
359
|
+
export function jobsForEvent(jobs, event, { registry, ctx = {}, admit = null } = {}) {
|
|
360
|
+
const out = [];
|
|
361
|
+
for (const job of jobs || []) {
|
|
362
|
+
if (!job.enabled) continue;
|
|
363
|
+
if (admit && !admit(job)) continue;
|
|
364
|
+
const trigger = registry?.get(job.trigger);
|
|
365
|
+
if (!trigger || trigger.kind === 'timer') continue;
|
|
366
|
+
if (trigger.watches.length && !trigger.watches.includes(event?.type)) continue;
|
|
367
|
+
let match = null;
|
|
368
|
+
try { match = trigger.matches(event, job.params, ctx); } catch { match = null; }
|
|
369
|
+
// A condition that threw did not match. Firing on an unanswered question is how
|
|
370
|
+
// automation does something nobody asked for.
|
|
371
|
+
if (match) out.push({ job, trigger, match, key: occurrenceKey(job.id, event.at || event.t || 0) });
|
|
372
|
+
}
|
|
373
|
+
return out;
|
|
374
|
+
}
|
package/scopes.js
CHANGED
|
@@ -9,4 +9,4 @@
|
|
|
9
9
|
// zero runtime dependencies by design; pulling the capability machinery and the event
|
|
10
10
|
// schema behind it to reach a five-element array would be the transitive-graph mistake
|
|
11
11
|
// the extension's first-paint budget exists to prevent, one repo over.
|
|
12
|
-
export const DATA_SCOPES = Object.freeze(['notes', 'meetings', 'chats', 'page', 'files', 'net']);
|
|
12
|
+
export const DATA_SCOPES = Object.freeze(['notes', 'meetings', 'chats', 'memory', 'page', 'files', 'net']);
|
package/view.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// CAPABILITY VIEWS — a capability may ship its own UI, so a result can be an interactive
|
|
2
|
+
// component instead of a paragraph of text.
|
|
3
|
+
//
|
|
4
|
+
// This is NOT the model writing HTML. That already exists (an ```html artifact runs in the
|
|
5
|
+
// sandbox) and is deliberately powerless: it is untrusted text, so it can only draw. A view
|
|
6
|
+
// is declared by a capability that a user or admin approved at load time, which is what
|
|
7
|
+
// makes it safe to give it something a model-authored page can never have — the ability to
|
|
8
|
+
// ACT, by invoking capabilities.
|
|
9
|
+
//
|
|
10
|
+
// The rule that keeps that safe is one line: a view may invoke only what its own capability
|
|
11
|
+
// is already allowed to invoke. A calculator view can compute because its capability may; it
|
|
12
|
+
// cannot read history unless that capability was granted `history` in the first place. There
|
|
13
|
+
// is no path here that widens a permission — `mayInvoke` can only ever name capabilities the
|
|
14
|
+
// declaring capability already lists in `requires`, and validateViewInvocation refuses
|
|
15
|
+
// anything outside that set before the kernel is ever consulted.
|
|
16
|
+
//
|
|
17
|
+
// Pure and host-free by design: no DOM, no postMessage, no chrome.*. The client owns the
|
|
18
|
+
// transport; this owns what is legal to say over it.
|
|
19
|
+
|
|
20
|
+
import { EventError } from './event.js';
|
|
21
|
+
|
|
22
|
+
const str = (v) => typeof v === 'string' && v.length > 0;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate a view DECLARATION. Like the rest of the capability contract this must be
|
|
26
|
+
* readable — and therefore approvable — without executing anything.
|
|
27
|
+
*/
|
|
28
|
+
export function validateView(view, capability = null) {
|
|
29
|
+
if (!view || typeof view !== 'object') throw new EventError('SHAPE', 'view must be an object');
|
|
30
|
+
if (!str(view.id)) throw new EventError('SHAPE', 'view.id required');
|
|
31
|
+
if (!str(view.html)) throw new EventError('SHAPE', 'view.html required — a self-contained document');
|
|
32
|
+
if (view.mayInvoke != null && !(Array.isArray(view.mayInvoke) && view.mayInvoke.every(str))) {
|
|
33
|
+
throw new EventError('SHAPE', 'view.mayInvoke must be string[]');
|
|
34
|
+
}
|
|
35
|
+
// NO PRIVILEGE ESCALATION BY DECLARATION. A view is part of its capability, so it cannot
|
|
36
|
+
// reach past it: every id it wants to call must already be in that capability's `requires`
|
|
37
|
+
// (or be the capability itself). Caught here, at approval time, rather than at call time.
|
|
38
|
+
if (capability && view.mayInvoke?.length) {
|
|
39
|
+
const allowed = new Set([capability.id, ...(capability.requires || [])]);
|
|
40
|
+
const extra = view.mayInvoke.filter((id) => !allowed.has(id));
|
|
41
|
+
if (extra.length) {
|
|
42
|
+
throw new EventError('CONTRADICTION',
|
|
43
|
+
`view.mayInvoke exceeds its capability: ${extra.join(', ')} not in requires`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (view.height != null && !(Number.isInteger(view.height) && view.height > 0 && view.height <= 2000)) {
|
|
47
|
+
throw new EventError('SHAPE', 'view.height must be a positive integer <= 2000');
|
|
48
|
+
}
|
|
49
|
+
return view;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Turn a message a VIEW sent into a capability invocation the kernel can judge — or refuse
|
|
54
|
+
* it. The view is sandboxed and its messages are untrusted input, so nothing here trusts a
|
|
55
|
+
* field: the capability id is checked against what the declaration allows, not against what
|
|
56
|
+
* the message claims to be entitled to.
|
|
57
|
+
*
|
|
58
|
+
* Returns { capability, args, callId }. Throws EventError otherwise.
|
|
59
|
+
*/
|
|
60
|
+
export function validateViewInvocation(msg, capability) {
|
|
61
|
+
if (!capability?.view) throw new EventError('SHAPE', 'capability declares no view');
|
|
62
|
+
if (!msg || typeof msg !== 'object') throw new EventError('SHAPE', 'view message must be an object');
|
|
63
|
+
if (!str(msg.callId)) throw new EventError('SHAPE', 'view message needs a callId to correlate its result');
|
|
64
|
+
if (!str(msg.capability)) throw new EventError('SHAPE', 'view message must name a capability');
|
|
65
|
+
|
|
66
|
+
const allowed = new Set([capability.id, ...(capability.view.mayInvoke || [])]);
|
|
67
|
+
if (!allowed.has(msg.capability)) {
|
|
68
|
+
// The important refusal. A compromised or buggy view asking for something else stops
|
|
69
|
+
// here, before any kernel guard has to have an opinion about it.
|
|
70
|
+
throw new EventError('DENIED',
|
|
71
|
+
`view of "${capability.id}" may not invoke "${msg.capability}"`);
|
|
72
|
+
}
|
|
73
|
+
if (msg.args != null && (typeof msg.args !== 'object' || Array.isArray(msg.args))) {
|
|
74
|
+
throw new EventError('SHAPE', 'view invocation args must be an object');
|
|
75
|
+
}
|
|
76
|
+
return { capability: msg.capability, args: msg.args || {}, callId: msg.callId };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The state a view is mounted with, as it appears on a capability RESULT. Kept separate from
|
|
81
|
+
* the canonical value: `value` is what the capability computed and what everything else
|
|
82
|
+
* reasons about; `view`/`state` are only how it is shown. A host that cannot render views
|
|
83
|
+
* ignores these two fields and still has the whole answer.
|
|
84
|
+
*/
|
|
85
|
+
export function viewResult(value, capability, state = null) {
|
|
86
|
+
if (!capability?.view) return { value };
|
|
87
|
+
return { value, view: capability.view.id, state: state ?? value };
|
|
88
|
+
}
|