@chatpanel/events 0.13.0 → 0.15.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 CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { CLASSES, EFFECTS, EGRESS, ACTOR_KINDS, SCOPE_KINDS, EventError } from './event.js';
14
14
  import { DATA_SCOPES } from './scopes.js';
15
+ import { validateView } from './view.js';
15
16
 
16
17
  export { DATA_SCOPES } from './scopes.js';
17
18
 
@@ -39,6 +40,9 @@ export function validateCapability(c) {
39
40
  if (!c.output || typeof c.output.render !== 'function') {
40
41
  throw new EventError('SHAPE', 'capability.output.render required — canonical value and rendering are separate');
41
42
  }
43
+ // A capability MAY ship its own UI. Optional, and validated against this capability so a
44
+ // view can never name a capability its owner isn't already allowed to call.
45
+ if (c.view != null) validateView(c.view, c);
42
46
  // A class-R capability that declares egress is a contradiction: R is a determinism
43
47
  // guarantee, and a network round-trip is not deterministic.
44
48
  if (c.class === 'R' && c.egress !== 'none') {
package/index.js CHANGED
@@ -29,6 +29,8 @@ export { defineSearchEngine, reconcileEngines, attemptOrder, ENGINE_KINDS, Searc
29
29
  export { defineToolGroup, createToolGroupRegistry, ToolGroupError } from './tool-groups.js';
30
30
  export { toolNeedFor } from './tool-need.js';
31
31
  export { parseFlowchart, layoutFlowchart, renderFlowchartSvg } from './flowchart.js';
32
+ export { validateView, validateViewInvocation, viewResult } from './view.js';
33
+ export { validateWidget, validateWidgetMessage, effectiveGrants, WIDGET_SURFACES } from './widget.js';
32
34
  export { fuseRRF, planQueries, multiSearch } from './rrf.js';
33
35
  export {
34
36
  ACCESS_LOG_VERSION, ACCESS_LOG_MAX, redactAccessArgs, makeAccessEvent,
@@ -50,6 +52,12 @@ export { createKernel, meetDecisions, KernelError, REQUIRED_PLUGINS, ALLOW_ALL }
50
52
  export { replay, formatReport, parseJsonl, toJsonl } from './harness.js';
51
53
  export { compileQuery, findMatches, matchIndexFor, expandReplacement, replaceMatch, replaceAll, replaceAllInRange, MAX_MATCHES } from './text-search.js';
52
54
  export { DATA_SCOPES } from './scopes.js';
55
+ export {
56
+ MEMORY_VERSION, MEMORY_KINDS, MEMORY_KIND_NAMES, AMBIENT_KINDS, MAX_MEMORY_CHARS, MIN_MEMORY_CHARS,
57
+ DEFAULT_MAX_MEMORIES, DEFAULT_BLOCK_CHARS, SAME_FACT, MEMORY_TOOL_SPEC, MEMORY_UPCASTERS, MemoryError,
58
+ normalizeMemory, isValidMemory, memoryKey, slotOf, similarity, containment, candidatesFrom, reconcile, matchForForget,
59
+ recall, memoryBlock, markUsed, pruneMemories, memoryToolSystem, upcastMemory,
60
+ } from './memory.js';
53
61
  export { SOURCE_TRUST, SkillSourceError, defineSkillSource, createSkillSourceRegistry } from './skill-sources.js';
54
62
  export { SKILL_MANIFEST_VERSION, SKILL_CONTEXTS, SKILL_HISTORY_SCOPES, SKILL_MCP_MODES, SKILL_TRUST, SKILL_FILE_KINDS, SKILL_UPCASTERS, SkillManifestError, isSafeSkillPath, originOf, trustOf, skillFiles, needsBridge, declaredAccess, originLabel, sameSkillOrigin, skillIsStale, validateSkill, upcastSkill, upcastSkills, normalizeSkill } from './skill-manifest.js';
55
63
  export { SKILL_VARS, SKILL_VAR_NAMES, skillVar, skillVarPattern, parseSkillVars, lintSkillPrompt, suggestSkillVar, substituteSkillVars, skillVarGuidance, SkillVarError } from './skill-vars.js';
package/memory.js ADDED
@@ -0,0 +1,638 @@
1
+ /**
2
+ * MEMORY — the small set of durable facts about the user that every model should already know.
3
+ *
4
+ * ChatPanel remembers conversations, meetings and notes, and can search all of them. What it
5
+ * could not do is KNOW anything. Told "call me Alex, and never open with a preamble", the next
6
+ * turn — let alone the next agent, or Codex over MCP — started from zero, because the only way
7
+ * a fact survived was for someone to search for it, and nobody searches for their own name.
8
+ *
9
+ * Memory is the opposite of history by design. History is large, retrieved, and about events.
10
+ * Memory is SMALL, ambient, and about standing truths — so it can be carried into every turn
11
+ * instead of looked up, and so a person can read the whole of it in one screen and correct it.
12
+ * That size limit is the feature: a memory that grows without bound becomes a second corpus,
13
+ * and a second corpus needs search, and then nothing is ambient any more.
14
+ *
15
+ * Four properties make it work the same everywhere:
16
+ *
17
+ * ONE VOCABULARY. Five kinds, closed set. `identity` and `preference` are AMBIENT — they
18
+ * apply to every turn, so they are carried unconditionally. `project`, `fact` and
19
+ * `reference` are RETRIEVED, because "the staging cluster is in Frankfurt" is only worth
20
+ * tokens on a turn that mentions it. One rule decides which, in `recall`, so the extension,
21
+ * the gateway and a future mobile client cannot disagree about what the model was told.
22
+ *
23
+ * CAPTURE IS DETERMINISTIC. `candidatesFrom` reads a string. No model call, no network, no
24
+ * latency, on every user message — which is the only reason it can run on every user
25
+ * message. It separates what the user COMMANDED ("remember that…") from what they merely
26
+ * REVEALED ("I prefer…"), because those deserve different answers: the first is consent,
27
+ * the second is a guess, and a guess must be offered rather than acted on.
28
+ *
29
+ * WRITES RECONCILE, THEY DO NOT ACCUMULATE. Saying "call me Alex" twice must leave one
30
+ * memory, and saying "actually, call me Sam" must leave one memory with the new value and
31
+ * the old one recoverable. `reconcile` decides create/update/duplicate against what is
32
+ * already stored; a client that just pushes rows produces a list nobody can read by week
33
+ * two.
34
+ *
35
+ * THE PROMPT FORM IS SHARED. `memoryBlock` is the one rendering. If the extension wrote its
36
+ * own the model would be told something subtly different depending on which surface the
37
+ * user typed into, and the bug would be invisible.
38
+ *
39
+ * Class R: pure, dependency-free, no I/O. Persistence is the host's (chrome.storage in the
40
+ * extension, an encrypted file in the gateway) — the same split as `store.js`.
41
+ */
42
+
43
+ export const MEMORY_VERSION = 1;
44
+
45
+ /**
46
+ * The closed vocabulary. Deliberately five: enough that a memory's kind tells you how to
47
+ * treat it, few enough that a person choosing one does not have to think.
48
+ *
49
+ * `ambient` is the load-bearing bit — it is not a label, it is the retrieval rule.
50
+ */
51
+ export const MEMORY_KINDS = Object.freeze({
52
+ identity: { label: 'Identity', ambient: true, hint: 'Who the user is — name, role, pronouns, language, timezone.' },
53
+ preference: { label: 'Preference', ambient: true, hint: 'How they want things done — tone, format, defaults, things never to do.' },
54
+ project: { label: 'Project', ambient: false, hint: 'Ongoing work, goals and constraints not derivable from the material itself.' },
55
+ fact: { label: 'Fact', ambient: false, hint: 'A durable fact about their world — systems, teams, environments, conventions.' },
56
+ reference: { label: 'Reference', ambient: false, hint: 'A pointer to something external — a URL, a dashboard, a ticket, a doc.' },
57
+ });
58
+
59
+ export const MEMORY_KIND_NAMES = Object.freeze(Object.keys(MEMORY_KINDS));
60
+
61
+ /** Kinds carried on every turn regardless of what was said. */
62
+ export const AMBIENT_KINDS = Object.freeze(MEMORY_KIND_NAMES.filter((k) => MEMORY_KINDS[k].ambient));
63
+
64
+ /**
65
+ * Bounds. A memory longer than this is a note, and there is already a notes feature; a store
66
+ * larger than this is a corpus, and there is already a history feature. Both limits exist to
67
+ * stop memory turning into the thing next to it.
68
+ */
69
+ export const MAX_MEMORY_CHARS = 280;
70
+ export const MIN_MEMORY_CHARS = 3;
71
+ export const DEFAULT_MAX_MEMORIES = 200;
72
+ /** Prompt budget for the injected block. ~100 tokens; enough for a readable standing brief. */
73
+ export const DEFAULT_BLOCK_CHARS = 1200;
74
+
75
+ export class MemoryError extends Error {
76
+ constructor(message) { super(message); this.name = 'MemoryError'; }
77
+ }
78
+
79
+ // --------------------------------------------------------------------------
80
+ // The record
81
+ // --------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Normalize anything memory-shaped into the canonical record. Throws MemoryError on input
85
+ * that cannot be a memory — callers get one validation path rather than each inventing
86
+ * their own defaults.
87
+ *
88
+ * `now` and `newId` are injected for the same reason they are in `event.js`: a pure module
89
+ * that reads the clock cannot be replayed or tested.
90
+ *
91
+ * @returns {{
92
+ * id: string, v: number, text: string, kind: string, scope: string, key: string,
93
+ * tags: string[], pinned: boolean, source: object, confidence: number,
94
+ * createdAt: number, updatedAt: number, usedAt: number, useCount: number,
95
+ * expiresAt: number|null, history: {text: string, at: number}[]
96
+ * }}
97
+ */
98
+ export function normalizeMemory(input = {}, { now = 0, newId = null } = {}) {
99
+ const text = collapse(input.text);
100
+ if (text.length < MIN_MEMORY_CHARS) throw new MemoryError('a memory needs text');
101
+ if (text.length > MAX_MEMORY_CHARS) {
102
+ throw new MemoryError(`a memory must be at most ${MAX_MEMORY_CHARS} characters — save longer material as a note`);
103
+ }
104
+ const kind = MEMORY_KINDS[input.kind] ? input.kind : 'fact';
105
+ const at = Number(input.createdAt) || Number(now) || 0;
106
+ return {
107
+ id: String(input.id || (newId ? newId() : '') || ''),
108
+ v: MEMORY_VERSION,
109
+ text,
110
+ kind,
111
+ // Free-form so a client can scope to an agent, a workspace or a site without this module
112
+ // enumerating surfaces it cannot know about. 'global' means every turn everywhere.
113
+ scope: String(input.scope || 'global'),
114
+ key: memoryKey(text),
115
+ // Derived, not asked for — see slotOf. An explicit slot still wins.
116
+ slot: collapse(input.slot).toLowerCase() || slotOf(text),
117
+ tags: [...new Set((input.tags || []).map((t) => collapse(t).toLowerCase()).filter(Boolean))].slice(0, 8),
118
+ pinned: !!input.pinned,
119
+ // WHERE IT CAME FROM, always. A memory the user cannot trace is a memory they cannot
120
+ // trust, and the first thing anyone asks of a wrong one is "when did I say that".
121
+ source: {
122
+ via: String(input.source?.via || 'user'), // user | agent | import | mcp
123
+ surface: String(input.source?.surface || ''), // chat | notes | meeting | mcp | settings
124
+ ref: String(input.source?.ref || ''), // conversation/meeting/note id
125
+ agent: String(input.source?.agent || ''), // which model or CLI proposed it
126
+ },
127
+ // How sure the CAPTURE was, not how true the fact is. An explicit command is 1.
128
+ confidence: clamp01(input.confidence == null ? 1 : Number(input.confidence)),
129
+ createdAt: at,
130
+ updatedAt: Number(input.updatedAt) || at,
131
+ usedAt: Number(input.usedAt) || 0,
132
+ useCount: Math.max(0, Math.floor(Number(input.useCount) || 0)),
133
+ expiresAt: input.expiresAt ? Number(input.expiresAt) : null,
134
+ // Bounded supersession ledger — "you used to say X" without an unbounded audit log.
135
+ history: (input.history || []).slice(-5).map((h) => ({ text: collapse(h.text), at: Number(h.at) || 0 })),
136
+ };
137
+ }
138
+
139
+ /** True when `rec` is a well-formed memory. Never throws — for filtering a loaded store. */
140
+ export function isValidMemory(rec) {
141
+ try { normalizeMemory(rec, { now: rec?.createdAt || 1 }); return !!rec?.id; } catch { return false; }
142
+ }
143
+
144
+ /**
145
+ * The identity of a FACT rather than of a record — two phrasings of the same standing truth
146
+ * should collide here so `reconcile` can supersede rather than accumulate.
147
+ *
148
+ * Lowercased, stripped of punctuation and of the framing words people vary freely ("I always
149
+ * prefer" / "prefer"), then the remaining words sorted and deduped. Sorting is safe precisely
150
+ * because this is a dedup key and never a display value: "deploy on fridays" and "on fridays,
151
+ * deploy" are the same standing fact, and treating them as two is the failure this prevents.
152
+ */
153
+ export function memoryKey(text) {
154
+ const words = collapse(text)
155
+ .toLowerCase()
156
+ .replace(/[^\p{L}\p{N}\s]/gu, ' ')
157
+ .split(/\s+/)
158
+ .filter((w) => w && !KEY_STOPWORDS.has(w));
159
+ return [...new Set(words)].sort().join(' ');
160
+ }
161
+
162
+ const KEY_STOPWORDS = new Set([
163
+ 'a', 'an', 'the', 'i', 'im', 'me', 'my', 'mine', 'we', 'our', 'us', 'you', 'your',
164
+ 'is', 'am', 'are', 'was', 'were', 'be', 'been', 'being', 'do', 'does', 'did',
165
+ 'to', 'of', 'in', 'on', 'at', 'for', 'with', 'and', 'or', 'that', 'this', 'it',
166
+ 'please', 'always', 'usually', 'generally', 'really', 'just', 'very', 'so',
167
+ 'remember', 'note', 'noting', 'user', 'prefer', 'prefers', 'preferred', 'like', 'likes',
168
+ ]);
169
+
170
+ /**
171
+ * THE SLOT A MEMORY FILLS, when it fills one — derived from the text itself.
172
+ *
173
+ * Token overlap cannot see that "Goes by Alex" and "Goes by Sam" are the same fact with a new
174
+ * value: they share two words out of four, which is exactly what two unrelated memories look
175
+ * like. So changing your name produced a SECOND memory and the model was told both.
176
+ *
177
+ * A slot is the subject a statement is about. Two memories with the same slot are one memory,
178
+ * whatever their words, so the later one supersedes. Deriving it from the text rather than
179
+ * asking the caller means it works identically for a captured phrase and for a `memory` tool
180
+ * call from some agent that has never heard of slots.
181
+ *
182
+ * Returns '' when a statement is not slot-shaped ("Deploys on Fridays"), which is most of
183
+ * them — those fall back to key and similarity matching.
184
+ */
185
+ export function slotOf(text) {
186
+ const t = collapse(text).toLowerCase().replace(/^the\s+/, '');
187
+ // Identity phrasings all name the same slot, or the user's name lives in three places.
188
+ if (/^(?:goes by|name is|is called|called)\b/.test(t)) return 'name';
189
+ if (/^pronouns\b/.test(t)) return 'pronouns';
190
+ // "<subject> is/are <value>" — the general form. Bounded to a short subject so a whole
191
+ // sentence containing "is" somewhere does not become a slot that swallows its neighbours.
192
+ const m = /^(?:my\s+|their\s+)?([\p{L}\p{N}][\p{L}\p{N} '-]{0,28}?)\s+(?:is|are|was|were)\b/u.exec(t);
193
+ if (!m) return '';
194
+ const subject = m[1].split(/\s+/).filter((w) => !KEY_STOPWORDS.has(w)).join(' ');
195
+ return subject.length >= 3 ? subject : '';
196
+ }
197
+
198
+ /** Token-set overlap, 0..1 — "prefers terse answers" vs "prefers terse replies". */
199
+ export function similarity(a, b) {
200
+ const A = new Set(memoryKey(a).split(' ').filter(Boolean));
201
+ const B = new Set(memoryKey(b).split(' ').filter(Boolean));
202
+ if (!A.size || !B.size) return 0;
203
+ let hit = 0;
204
+ for (const w of A) if (B.has(w)) hit += 1;
205
+ return hit / (A.size + B.size - hit);
206
+ }
207
+
208
+ /**
209
+ * How much of the SHORTER phrase the longer one contains, 0..1. Different question from
210
+ * `similarity`, and the right one for "forget the Frankfurt thing": a person names a memory by
211
+ * one distinctive word, not by restating it, so a symmetric measure scores that near zero.
212
+ */
213
+ export function containment(a, b) {
214
+ const A = new Set(memoryKey(a).split(' ').filter(Boolean));
215
+ const B = new Set(memoryKey(b).split(' ').filter(Boolean));
216
+ if (!A.size || !B.size) return 0;
217
+ let hit = 0;
218
+ for (const w of A) if (B.has(w)) hit += 1;
219
+ return hit / Math.min(A.size, B.size);
220
+ }
221
+
222
+ /** Above this, two memories are the same standing fact stated differently. */
223
+ export const SAME_FACT = 0.8;
224
+
225
+ // --------------------------------------------------------------------------
226
+ // Capture — reading a message for things worth keeping
227
+ // --------------------------------------------------------------------------
228
+
229
+ /**
230
+ * COMMANDS. The user is addressing the assistant and telling it to keep something. The capture
231
+ * group is the fact itself, so "remember that I deploy on Fridays" stores "I deploy on
232
+ * Fridays" and not the instruction wrapping it.
233
+ *
234
+ * Each carries the kind it implies, because "call me Alex" is an identity and "from now on,
235
+ * be terse" is a preference, and making the user pick afterwards is a step they should not
236
+ * have to take.
237
+ */
238
+ const COMMANDS = [
239
+ { re: /^(?:please\s+)?(?:remember|memorize|memorise)(?:\s+that|\s+this)?[:,]?\s+(.+)$/i, kind: 'fact' },
240
+ { re: /^(?:please\s+)?(?:keep in mind|bear in mind|don'?t forget|do not forget)(?:\s+that)?[:,]?\s+(.+)$/i, kind: 'fact' },
241
+ { re: /^(?:please\s+)?(?:make a note|note)(?:\s+that|\s+of)[:,]?\s+(.+)$/i, kind: 'fact' },
242
+ { re: /^(?:from now on|going forward|in future|in the future|henceforth)[:,]?\s+(.+)$/i, kind: 'preference' },
243
+ { re: /^(?:call me|i go by|refer to me as)\s+(.+)$/i, kind: 'identity', rebuild: (m) => `Goes by ${trimEnd(m[1])}` },
244
+ { re: /^my name(?:'s| is)\s+(.+)$/i, kind: 'identity', rebuild: (m) => `Name is ${trimEnd(m[1])}` },
245
+ { re: /^(?:always|never)\s+(.+)$/i, kind: 'preference', rebuild: (m, raw) => sentence(raw) },
246
+ ];
247
+
248
+ /**
249
+ * REVEALS. Not addressed to the assistant at all — the user simply said something durable
250
+ * about themselves in passing. These are OFFERED, never saved, because the inference is
251
+ * exactly the kind that is right often enough to be useful and wrong often enough to be
252
+ * insulting if acted on silently.
253
+ */
254
+ const REVEALS = [
255
+ { re: /^i (?:prefer|like|want|need)\s+(.+)$/i, kind: 'preference', confidence: 0.7 },
256
+ { re: /^i (?:hate|dislike|don'?t like|do not like|can'?t stand)\s+(.+)$/i, kind: 'preference', confidence: 0.7 },
257
+ { re: /^i(?:'m| am)(?: a| an| the)?\s+(.+)$/i, kind: 'identity', confidence: 0.6 },
258
+ { re: /^i (?:work|working) (?:on|at|with)\s+(.+)$/i, kind: 'project', confidence: 0.6 },
259
+ { re: /^i(?:'m| am) (?:working|building|writing) (?:on\s+)?(.+)$/i, kind: 'project', confidence: 0.6 },
260
+ { re: /^(?:we|our team|my team) (?:use|uses|are using|run|runs|deploy|deploys)\s+(.+)$/i, kind: 'fact', confidence: 0.6 },
261
+ { re: /^my (?:\w+\s){0,2}?(?:is|are)\s+(.+)$/i, kind: 'fact', confidence: 0.55 },
262
+ ];
263
+
264
+ /** Removal is a command too, and it must be recognised or "forget that" gets stored as a fact. */
265
+ const FORGETS = [
266
+ /^(?:please\s+)?forget(?:\s+that|\s+about)?[:,]?\s+(.+)$/i,
267
+ /^(?:please\s+)?(?:stop remembering|no longer remember|un-?remember)[:,]?\s+(.+)$/i,
268
+ ];
269
+
270
+ /**
271
+ * A trigger word inside a QUESTION is not an instruction — "do you remember what we decided?"
272
+ * asks for recall, and storing it as a fact is the single most obvious way to make this
273
+ * feature look broken. Likewise "I can't remember" is a complaint, not a command.
274
+ */
275
+ const NOT_A_COMMAND = /(?:^|\s)(?:do|does|did|can|could|would|will|what|when|where|who|why|how|are|is)\s+(?:you|i|we|it)\b/i;
276
+ const NEGATED = /\b(?:can'?t|cannot|don'?t|do not|couldn'?t|never|didn'?t)\s+(?:seem to\s+)?(?:remember|recall|forget)\b/i;
277
+
278
+ /**
279
+ * Read one user message for things worth keeping.
280
+ *
281
+ * Returns candidates in the order found. `explicit: true` means the user issued a command and
282
+ * the host may save it without asking; `explicit: false` means offer it and let them tap.
283
+ * That distinction is the whole capture policy, and it lives here rather than in a client so
284
+ * the panel, the gateway and a mobile app cannot each pick a different one.
285
+ *
286
+ * @param text one user message.
287
+ * @param opts.maxCandidates cap per message (default 3) — a wall of chips is not a prompt.
288
+ * @param opts.includeReveals set false for surfaces with nowhere to show an offer.
289
+ * @returns {{op: 'remember'|'forget', text: string, kind: string, confidence: number,
290
+ * explicit: boolean, trigger: string}[]}
291
+ */
292
+ export function candidatesFrom(text, { maxCandidates = 3, includeReveals = true } = {}) {
293
+ const out = [];
294
+ const raw = String(text || '');
295
+ // Fenced code is material, not speech. A README line that happens to start "I prefer" is
296
+ // not the user telling us anything.
297
+ const speech = raw.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`]*`/g, ' ');
298
+
299
+ for (const line of splitStatements(speech)) {
300
+ if (out.length >= maxCandidates) break;
301
+ if (line.length < MIN_MEMORY_CHARS) continue;
302
+ if (NEGATED.test(line)) continue;
303
+
304
+ let matched = false;
305
+ for (const re of FORGETS) {
306
+ const m = re.exec(line);
307
+ if (!m) continue;
308
+ const body = clip(trimEnd(m[1]));
309
+ if (body.length >= MIN_MEMORY_CHARS) {
310
+ out.push({ op: 'forget', text: body, kind: 'fact', confidence: 1, explicit: true, trigger: 'forget' });
311
+ matched = true;
312
+ }
313
+ break;
314
+ }
315
+ if (matched) continue;
316
+
317
+ // A question mark alone is not disqualifying — "remember that the demo is Friday?" is a
318
+ // command with a tag. It is the interrogative SHAPE that rules a command out.
319
+ if (NOT_A_COMMAND.test(line)) continue;
320
+
321
+ for (const { re, kind, rebuild } of COMMANDS) {
322
+ const m = re.exec(line);
323
+ if (!m) continue;
324
+ const body = clip(rebuild ? rebuild(m, line) : sentence(trimEnd(m[1])));
325
+ if (body.length >= MIN_MEMORY_CHARS) {
326
+ out.push({ op: 'remember', text: body, kind, confidence: 1, explicit: true, trigger: 'command' });
327
+ matched = true;
328
+ }
329
+ break;
330
+ }
331
+ if (matched || !includeReveals) continue;
332
+
333
+ for (const { re, kind, confidence } of REVEALS) {
334
+ if (!re.test(line)) continue;
335
+ const body = clip(sentence(line));
336
+ // Two words minimum: "I am tired" is durable-looking and worthless. The floor is crude
337
+ // on purpose — the cost of a bad offer is one ignored chip, and the cost of a clever
338
+ // filter is a fact the user watched get dropped.
339
+ if (body.split(/\s+/).length >= 3) {
340
+ out.push({ op: 'remember', text: body, kind, confidence, explicit: false, trigger: 'reveal' });
341
+ }
342
+ break;
343
+ }
344
+ }
345
+ return out.slice(0, maxCandidates);
346
+ }
347
+
348
+ // --------------------------------------------------------------------------
349
+ // Reconcile — what a write does to what is already there
350
+ // --------------------------------------------------------------------------
351
+
352
+ /**
353
+ * Decide what saving `incoming` means against the memories already held.
354
+ *
355
+ * duplicate — the same fact, said the same way. Nothing to do but touch it.
356
+ * update — the same fact, restated or changed. Supersede in place, keep the old text.
357
+ * create — genuinely new.
358
+ *
359
+ * There is deliberately no `conflict`. Two contradictory statements cannot be told apart from
360
+ * a correction without understanding the sentence, and a memory system that asks "did you
361
+ * mean to change your mind?" is a memory system people turn off. The later statement wins and
362
+ * the earlier one stays visible in `history`.
363
+ *
364
+ * @returns {{action: 'create'|'update'|'duplicate', record: object, replaces: object|null}}
365
+ */
366
+ export function reconcile(memories, incoming, { now = 0, newId = null } = {}) {
367
+ const next = normalizeMemory(incoming, { now, newId });
368
+ const pool = (memories || []).filter((m) => m && m.scope === next.scope);
369
+
370
+ // Slot first: it is the only one of the three that can recognise a CHANGED VALUE, which is
371
+ // what a correction is. Then exact key, then near-identical wording.
372
+ const match = (next.slot && pool.find((m) => m.slot === next.slot))
373
+ || pool.find((m) => m.key === next.key)
374
+ || pool.find((m) => m.kind === next.kind && similarity(m.text, next.text) >= SAME_FACT);
375
+
376
+ if (!match) return { action: 'create', record: next, replaces: null };
377
+
378
+ if (collapse(match.text).toLowerCase() === next.text.toLowerCase()) {
379
+ // Restating a memory is a signal about it: it is still true, and it is on the user's mind.
380
+ return {
381
+ action: 'duplicate',
382
+ record: { ...match, updatedAt: Number(now) || match.updatedAt, useCount: match.useCount + 1 },
383
+ replaces: match,
384
+ };
385
+ }
386
+
387
+ return {
388
+ action: 'update',
389
+ record: {
390
+ ...match,
391
+ text: next.text,
392
+ key: next.key,
393
+ slot: next.slot,
394
+ kind: next.kind,
395
+ tags: next.tags.length ? next.tags : match.tags,
396
+ confidence: next.confidence,
397
+ source: next.source,
398
+ updatedAt: Number(now) || match.updatedAt,
399
+ history: [...match.history, { text: match.text, at: match.updatedAt }].slice(-5),
400
+ },
401
+ replaces: match,
402
+ };
403
+ }
404
+
405
+ /**
406
+ * Which stored memories a "forget X" refers to. Matches by id, then by exact key, then by
407
+ * similarity — a person says "forget the Frankfurt thing", not a uuid.
408
+ */
409
+ export function matchForForget(memories, query, { limit = 5 } = {}) {
410
+ const q = collapse(query);
411
+ if (!q) return [];
412
+ const byId = (memories || []).filter((m) => m.id === q);
413
+ if (byId.length) return byId;
414
+ const key = memoryKey(q);
415
+ return (memories || [])
416
+ .map((m) => ({ m, s: m.key === key ? 1 : Math.max(similarity(m.text, q), containment(q, m.text)) }))
417
+ .filter((x) => x.s >= 0.5)
418
+ .sort((a, b) => b.s - a.s)
419
+ .slice(0, limit)
420
+ .map((x) => x.m);
421
+ }
422
+
423
+ // --------------------------------------------------------------------------
424
+ // Recall — what the model is told, this turn
425
+ // --------------------------------------------------------------------------
426
+
427
+ /**
428
+ * Choose the memories for one turn, within a character budget.
429
+ *
430
+ * Ambient kinds (identity, preference) come first and unconditionally: they are how the user
431
+ * wants to be spoken to, and a turn that fails to mention their name is still a turn where
432
+ * their name applies. Everything else is scored against the turn's text — a project memory
433
+ * earns its tokens only when the turn is about it.
434
+ *
435
+ * Pinned always wins, of any kind. That is the user's explicit override of this whole ranking.
436
+ *
437
+ * @param memories all stored memories.
438
+ * @param opts.text the turn's text (user message, or the question for a tool call).
439
+ * @param opts.scopes which scopes apply — 'global' plus, say, `agent:claude-code`.
440
+ * @param opts.maxChars budget for the rendered block.
441
+ * @returns the chosen memories, most important first.
442
+ */
443
+ export function recall(memories, {
444
+ text = '', scopes = ['global'], kinds = null, now = 0,
445
+ limit = 24, maxChars = DEFAULT_BLOCK_CHARS, includeAmbient = true,
446
+ } = {}) {
447
+ const scopeSet = new Set(scopes.length ? scopes : ['global']);
448
+ const terms = new Set(memoryKey(text).split(' ').filter(Boolean));
449
+
450
+ const live = (memories || [])
451
+ .filter((m) => m && m.text)
452
+ .filter((m) => scopeSet.has(m.scope))
453
+ .filter((m) => !kinds || kinds.includes(m.kind))
454
+ .filter((m) => !m.expiresAt || !now || m.expiresAt > now);
455
+
456
+ const scored = live.map((m) => {
457
+ const words = new Set(m.key.split(' ').filter(Boolean));
458
+ let overlap = 0;
459
+ for (const w of words) if (terms.has(w)) overlap += 1;
460
+ const relevance = words.size ? overlap / words.size : 0;
461
+ const ambient = includeAmbient && MEMORY_KINDS[m.kind]?.ambient;
462
+ // Recency and use are TIE-BREAKS, not drivers. A fact does not become truer because it
463
+ // was mentioned recently, but between two equally relevant ones the live one is the
464
+ // better guess.
465
+ const freshness = now && m.updatedAt ? Math.max(0, 1 - (now - m.updatedAt) / YEAR) : 0;
466
+ const used = Math.min(1, m.useCount / 10);
467
+ return {
468
+ m,
469
+ keep: m.pinned || ambient || relevance > 0,
470
+ score: (m.pinned ? 100 : 0) + (ambient ? 10 : 0) + relevance * 8 + freshness + used,
471
+ };
472
+ });
473
+
474
+ const out = [];
475
+ let chars = 0;
476
+ for (const { m, keep, score } of scored.filter((s) => s.keep).sort((a, b) => b.score - a.score)) {
477
+ if (out.length >= limit) break;
478
+ const cost = m.text.length + m.kind.length + 6;
479
+ if (chars + cost > maxChars && out.length) break;
480
+ chars += cost;
481
+ out.push(m);
482
+ void score;
483
+ }
484
+ return out;
485
+ }
486
+
487
+ const YEAR = 365 * 24 * 60 * 60 * 1000;
488
+
489
+ /**
490
+ * The ONE prompt rendering. Every surface that puts memory in front of a model uses this, so
491
+ * "what the model was told" is a single, reviewable string rather than per-client prose.
492
+ *
493
+ * Returns '' for an empty set — callers can concatenate unconditionally.
494
+ */
495
+ export function memoryBlock(memories, { heading = 'What you already know about this user', maxChars = DEFAULT_BLOCK_CHARS } = {}) {
496
+ const list = (memories || []).filter((m) => m && m.text);
497
+ if (!list.length) return '';
498
+ const lines = [];
499
+ let chars = 0;
500
+ for (const m of list) {
501
+ const line = `- (${m.kind}) ${m.text}`;
502
+ if (chars + line.length > maxChars && lines.length) break;
503
+ chars += line.length + 1;
504
+ lines.push(line);
505
+ }
506
+ return [
507
+ `## ${heading}`,
508
+ 'Saved by the user in ChatPanel and true across conversations, agents and devices.',
509
+ 'Apply them without being asked and without announcing them.',
510
+ ...lines,
511
+ 'If the user states something durable about themselves, their preferences or their work,'
512
+ + ' save it with the `memory` tool. If they correct one of these, update it — do not just agree.',
513
+ ].join('\n');
514
+ }
515
+
516
+ /** Mark memories as used this turn. Recall quality depends on it, so it is not optional. */
517
+ export function markUsed(memories, ids, { now = 0 } = {}) {
518
+ const set = new Set(ids || []);
519
+ return (memories || []).map((m) => (set.has(m.id)
520
+ ? { ...m, usedAt: Number(now) || m.usedAt, useCount: m.useCount + 1 }
521
+ : m));
522
+ }
523
+
524
+ /**
525
+ * Drop what has expired, then hold the store to `max` by evicting the least valuable.
526
+ *
527
+ * Eviction order is the inverse of value: never pinned, never ambient, then least used and
528
+ * least recently touched. Returns both halves so a client can TELL the user what went rather
529
+ * than silently shrinking their memory.
530
+ */
531
+ export function pruneMemories(memories, { now = 0, max = DEFAULT_MAX_MEMORIES } = {}) {
532
+ const all = (memories || []).filter(Boolean);
533
+ const expired = now ? all.filter((m) => m.expiresAt && m.expiresAt <= now) : [];
534
+ let kept = expired.length ? all.filter((m) => !expired.includes(m)) : all;
535
+ const dropped = [...expired];
536
+
537
+ if (kept.length > max) {
538
+ const value = (m) => (m.pinned ? 3 : 0) + (MEMORY_KINDS[m.kind]?.ambient ? 1 : 0);
539
+ const ranked = [...kept].sort((a, b) => value(b) - value(a)
540
+ || b.useCount - a.useCount
541
+ || (b.usedAt || b.updatedAt) - (a.usedAt || a.updatedAt));
542
+ dropped.push(...ranked.slice(max));
543
+ kept = ranked.slice(0, max);
544
+ }
545
+ return { kept, dropped };
546
+ }
547
+
548
+ // --------------------------------------------------------------------------
549
+ // The tool contract — one definition, every client
550
+ // --------------------------------------------------------------------------
551
+
552
+ /**
553
+ * The `memory` tool as the model sees it, shared by the extension's turn toolset and the
554
+ * gateway's MCP server. A second copy would drift, and the drift would be a model that
555
+ * behaves differently in Claude Code than in the side panel for no reason a user could see.
556
+ *
557
+ * One tool with an `action`, not four tools: memory is a small feature and four schemas
558
+ * resident on every turn would cost more than the memories themselves.
559
+ */
560
+ export const MEMORY_TOOL_SPEC = Object.freeze({
561
+ name: 'memory',
562
+ description:
563
+ "Save, update or remove a durable fact about the USER in ChatPanel — carried into every "
564
+ + 'future conversation, on every model and agent. Use it the moment they state a standing '
565
+ + 'preference ("always be terse"), an identity fact ("call me Alex"), or a constraint about '
566
+ + 'their work that will still be true next week. Do NOT use it for one-off task details, '
567
+ + 'anything already obvious from the conversation, or content that belongs in a note. '
568
+ + 'Keep each memory one short self-contained sentence. Use `forget` when the user says '
569
+ + 'something is no longer true, and `list` to see what is already stored before adding.',
570
+ parameters: {
571
+ type: 'object',
572
+ properties: {
573
+ action: { type: 'string', enum: ['remember', 'forget', 'list'], description: 'What to do.' },
574
+ text: { type: 'string', description: `The fact, as one short sentence written in the third person ("Prefers terse answers"). Max ${MAX_MEMORY_CHARS} characters. Required for remember; for forget, the memory to drop (its text or id).` },
575
+ kind: { type: 'string', enum: MEMORY_KIND_NAMES, description: MEMORY_KIND_NAMES.map((k) => `${k}: ${MEMORY_KINDS[k].hint}`).join(' ') },
576
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional short tags for grouping.' },
577
+ },
578
+ required: ['action'],
579
+ },
580
+ });
581
+
582
+ /** The system text that goes with the tool — why it exists, when NOT to reach for it. */
583
+ export function memoryToolSystem() {
584
+ return [
585
+ 'You can give the user a memory that survives this conversation, this model and this device'
586
+ + ' — call the `memory` tool.',
587
+ 'Save when they say something durable about themselves, how they want to be helped, or their'
588
+ + ' ongoing work. Do not save task details, transient state, or anything they would not want'
589
+ + ' repeated back to them in a month.',
590
+ 'Saving is a change to the user\'s own data: say in one short clause what you saved.',
591
+ ].join(' ');
592
+ }
593
+
594
+ // --------------------------------------------------------------------------
595
+ // Versioning
596
+ // --------------------------------------------------------------------------
597
+
598
+ /**
599
+ * Upcasters, empty at v1 — present so the first schema change is a one-line addition rather
600
+ * than a migration nobody planned for. Same machinery as `upcast.js`.
601
+ */
602
+ export const MEMORY_UPCASTERS = Object.freeze({});
603
+
604
+ export function upcastMemory(rec) {
605
+ let out = rec;
606
+ for (let v = Number(out?.v) || 1; v < MEMORY_VERSION; v += 1) {
607
+ const up = MEMORY_UPCASTERS[v];
608
+ if (!up) throw new MemoryError(`no upcaster from memory v${v}`);
609
+ out = up(out);
610
+ }
611
+ return out;
612
+ }
613
+
614
+ // --------------------------------------------------------------------------
615
+ // Text helpers
616
+ // --------------------------------------------------------------------------
617
+
618
+ const collapse = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
619
+ const clamp01 = (n) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 1);
620
+ const trimEnd = (s) => collapse(s).replace(/[.,;:!]+$/, '');
621
+ const clip = (s) => (s.length > MAX_MEMORY_CHARS ? `${s.slice(0, MAX_MEMORY_CHARS - 1).trimEnd()}…` : s);
622
+ const sentence = (s) => {
623
+ const t = trimEnd(s);
624
+ return t ? t[0].toUpperCase() + t.slice(1) : t;
625
+ };
626
+
627
+ /**
628
+ * Split a message into the statements a trigger could apply to. Newlines and list bullets are
629
+ * hard boundaries; sentence-ending punctuation is a soft one. Deliberately does NOT split on
630
+ * commas — "remember that we ship on Friday, not Thursday" is one fact.
631
+ */
632
+ function splitStatements(text) {
633
+ return String(text || '')
634
+ .split(/\n+/)
635
+ .flatMap((line) => collapse(line).replace(/^[-*+•]\s*|^\d+[.)]\s*/, '').split(/(?<=[.!?])\s+(?=[A-Z"'])/))
636
+ .map(collapse)
637
+ .filter(Boolean);
638
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.13.0",
3
+ "version": "0.15.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,6 +18,7 @@
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",
@@ -40,7 +41,9 @@
40
41
  "./upcast.js": "./upcast.js",
41
42
  "./observability.js": "./observability.js",
42
43
  "./flowchart.js": "./flowchart.js",
43
- "./rrf.js": "./rrf.js"
44
+ "./rrf.js": "./rrf.js",
45
+ "./view.js": "./view.js",
46
+ "./widget.js": "./widget.js"
44
47
  },
45
48
  "files": [
46
49
  "LICENSE",
@@ -59,6 +62,7 @@
59
62
  "markdown-authoring.js",
60
63
  "mcp-errors.js",
61
64
  "meeting-analyzers.js",
65
+ "memory.js",
62
66
  "observability.js",
63
67
  "order.js",
64
68
  "ref.js",
@@ -80,7 +84,9 @@
80
84
  "tool-groups.js",
81
85
  "tool-need.js",
82
86
  "trajectory.js",
83
- "upcast.js"
87
+ "upcast.js",
88
+ "view.js",
89
+ "widget.js"
84
90
  ],
85
91
  "scripts": {
86
92
  "test": "node --test tests/*.test.js"
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/trajectory.js CHANGED
@@ -44,7 +44,7 @@ export function displayName(call) {
44
44
  }
45
45
 
46
46
  /** Entry kinds, in the order they conventionally appear. Used for grouping and colour. */
47
- export const ENTRY_KINDS = Object.freeze(['system', 'user', 'context', 'route', 'tool', 'result', 'reasoning', 'assistant']);
47
+ export const ENTRY_KINDS = Object.freeze(['system', 'user', 'context', 'route', 'privacy', 'tool', 'result', 'reasoning', 'assistant']);
48
48
 
49
49
  const short = (s, n = 120) => {
50
50
  const t = String(s ?? '').replace(/\s+/g, ' ').trim();
@@ -92,6 +92,27 @@ export function buildTrajectory(events) {
92
92
  });
93
93
  break;
94
94
 
95
+ // WHAT LEFT THE DEVICE, AND IN WHAT SHAPE. A trajectory that shows the prompt, the
96
+ // tools and the answer but not the redaction cannot answer the question the product
97
+ // exists to answer — "what did the model actually see?". Counts by entity type only;
98
+ // the values are never in the event (see the privacy.redacted contract).
99
+ case 'privacy.redacted': {
100
+ const counts = p.counts || {};
101
+ const total = Object.values(counts).reduce((n, v) => n + (Number(v) || 0), 0);
102
+ if (!total) break;
103
+ const byType = Object.entries(counts)
104
+ .sort((a, b) => b[1] - a[1])
105
+ .map(([type, n]) => `${n} ${type.toLowerCase().replace(/_/g, ' ')}`)
106
+ .join(' · ');
107
+ entries.push({
108
+ kind: 'privacy', at: e.at,
109
+ title: `Redacted ${total} value${total === 1 ? '' : 's'} before sending`,
110
+ detail: byType,
111
+ data: { counts },
112
+ });
113
+ break;
114
+ }
115
+
95
116
  case 'assistant.prompted':
96
117
  entries.push({ kind: 'system', at: e.at, title: 'Prompt', detail: `${p.chars || 0} chars`, ref: p.ref, expandsToMessages: true });
97
118
  break;
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
+ }
package/widget.js ADDED
@@ -0,0 +1,110 @@
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
+ if (w.surface != null && !WIDGET_SURFACES.includes(w.surface)) {
46
+ throw new EventError('SHAPE', `widget.surface must be one of ${WIDGET_SURFACES}`);
47
+ }
48
+ if (w.height != null && !(Number.isInteger(w.height) && w.height > 0 && w.height <= 2000)) {
49
+ throw new EventError('SHAPE', 'widget.height must be a positive integer <= 2000');
50
+ }
51
+ // Requesting is not receiving. This only records what the widget WANTS; the grant lives
52
+ // outside the manifest so rewriting the widget can never widen its own permissions.
53
+ if (w.requests != null && !(Array.isArray(w.requests) && w.requests.every(str))) {
54
+ throw new EventError('SHAPE', 'widget.requests must be string[] (capability ids it asks for)');
55
+ }
56
+ return w;
57
+ }
58
+
59
+ /**
60
+ * Validate a message a widget sent. Untrusted input: nothing is believed, and the widget's
61
+ * own id is supplied by the HOST (which knows which frame sent it), never read from the
62
+ * message — otherwise a widget could name someone else's id and read their state.
63
+ */
64
+ export function validateWidgetMessage(msg, { widgetId, grants = [] } = {}) {
65
+ if (!str(widgetId)) throw new EventError('SHAPE', 'host must supply the sending widget id');
66
+ if (!msg || typeof msg !== 'object') throw new EventError('SHAPE', 'widget message must be an object');
67
+ if (!str(msg.callId)) throw new EventError('SHAPE', 'widget message needs a callId');
68
+
69
+ switch (msg.op) {
70
+ case 'state.get':
71
+ return { op: 'state.get', widgetId, callId: msg.callId };
72
+
73
+ case 'state.set': {
74
+ if (msg.state === undefined) throw new EventError('SHAPE', 'state.set needs state');
75
+ let size = 0;
76
+ try { size = JSON.stringify(msg.state ?? null).length; }
77
+ catch { throw new EventError('SHAPE', 'widget state must be JSON-serialisable'); }
78
+ if (size > MAX_STATE) throw new EventError('SHAPE', `widget state exceeds ${MAX_STATE} bytes`);
79
+ return { op: 'state.set', widgetId, callId: msg.callId, state: msg.state };
80
+ }
81
+
82
+ case 'invoke': {
83
+ if (!str(msg.capability)) throw new EventError('SHAPE', 'invoke must name a capability');
84
+ // THE REFUSAL THAT MATTERS. A widget gets what the user granted it and nothing else —
85
+ // checked here, before any kernel guard has to have an opinion, and against the stored
86
+ // grants rather than against anything the widget or its manifest claims.
87
+ if (!grants.includes(msg.capability)) {
88
+ throw new EventError('DENIED',
89
+ `widget "${widgetId}" has no grant for "${msg.capability}" — the user must approve it first`);
90
+ }
91
+ if (msg.args != null && (typeof msg.args !== 'object' || Array.isArray(msg.args))) {
92
+ throw new EventError('SHAPE', 'invoke args must be an object');
93
+ }
94
+ return { op: 'invoke', widgetId, callId: msg.callId, capability: msg.capability, args: msg.args || {} };
95
+ }
96
+
97
+ default:
98
+ throw new EventError('SHAPE', `unknown widget op "${msg.op}"`);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * The permissions a widget actually holds: the intersection of what it asked for and what the
104
+ * user approved. Asking for more later cannot grant it — a request that was never approved
105
+ * stays absent, so an updated widget re-asking silently gains nothing.
106
+ */
107
+ export function effectiveGrants(widget, approved = []) {
108
+ const asked = new Set(widget?.requests || []);
109
+ return (approved || []).filter((id) => asked.has(id));
110
+ }