@chatpanel/events 0.2.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/store.js ADDED
@@ -0,0 +1,171 @@
1
+ // The log store and the blob store.
2
+ //
3
+ // PERSISTENCE IS A HOST ADAPTER. The spine is host-independent, so the *semantics* live
4
+ // here and each host supplies the storage underneath: IndexedDB in the extension,
5
+ // SQLite on a gateway or daemon, a capped ring buffer colocated. One in-memory adapter
6
+ // ships here because tests, colocated hosts and the replay harness all want it.
7
+ //
8
+ // TWO STORES, DELIBERATELY SEPARATE.
9
+ // • the LOG holds events — small, uniform, append-only, ~300-500 B each;
10
+ // • the BLOB store holds content, addressed by hash and deduped, so the same note
11
+ // excerpt attached across a hundred turns costs one copy.
12
+ // Keeping them apart is what makes crypto-shredding work: drop a blob and the event
13
+ // skeleton, causality chain and audit trail all survive.
14
+ //
15
+ // APPEND IS IDEMPOTENT ON EVENT ID. Replicating the log to a warm tier can retry
16
+ // safely — the log-level counterpart of the idempotency keys capabilities carry.
17
+
18
+ import { validateEvent, EventError } from './event.js';
19
+ import { linearize } from './order.js';
20
+
21
+ /**
22
+ * Minimal adapter contract a host implements.
23
+ * get(key) -> value | undefined put(key, value) delete(key)
24
+ * keys(prefix?) -> string[] size() -> bytes (approximate)
25
+ */
26
+ export function createMemoryAdapter() {
27
+ const map = new Map();
28
+ return {
29
+ get: (k) => map.get(k),
30
+ put: (k, v) => { map.set(k, v); },
31
+ delete: (k) => map.delete(k),
32
+ keys: (prefix = '') => [...map.keys()].filter((k) => k.startsWith(prefix)).sort(),
33
+ size: () => {
34
+ let n = 0;
35
+ for (const [k, v] of map) n += k.length + (typeof v === 'string' ? v.length : JSON.stringify(v).length);
36
+ return n;
37
+ },
38
+ clear: () => map.clear(),
39
+ };
40
+ }
41
+
42
+ const EV = 'e/'; // event by id
43
+ const SEQ = 's/'; // host -> highest seq seen
44
+
45
+ /** The append-only event log over a host adapter. */
46
+ export function createLogStore(adapter = createMemoryAdapter()) {
47
+ const store = {
48
+ /**
49
+ * Append a validated event. Idempotent on `id`: re-appending the same event is a
50
+ * no-op returning `{ appended: false }`, so replication retries are safe.
51
+ * Rejects a seq that moves backwards for a host — that is a corrupt writer, not a gap.
52
+ */
53
+ append(event) {
54
+ validateEvent(event);
55
+ if (adapter.get(EV + event.id) !== undefined) return { appended: false, event };
56
+ const highest = adapter.get(SEQ + event.host);
57
+ if (highest !== undefined && event.seq <= highest) {
58
+ throw new EventError('SEQ', `seq ${event.seq} <= ${highest} already seen for host ${event.host}`, event.host);
59
+ }
60
+ adapter.put(EV + event.id, event);
61
+ adapter.put(SEQ + event.host, event.seq);
62
+ return { appended: true, event };
63
+ },
64
+
65
+ appendAll(events) {
66
+ const res = events.map((e) => store.append(e));
67
+ return { appended: res.filter((r) => r.appended).length, total: events.length };
68
+ },
69
+
70
+ get: (id) => adapter.get(EV + id),
71
+ has: (id) => adapter.get(EV + id) !== undefined,
72
+
73
+ all: () => adapter.keys(EV).map((k) => adapter.get(k)),
74
+
75
+ /** Every event in replay order — the only ordering anything should read. */
76
+ ordered: () => linearize(store.all()),
77
+
78
+ byType: (type) => store.all().filter((e) => e.type === type),
79
+
80
+ /** One host's slice, for replication cursors. */
81
+ range({ host, fromSeq = 0, toSeq = Infinity } = {}) {
82
+ return store.all()
83
+ .filter((e) => (!host || e.host === host) && e.seq >= fromSeq && e.seq <= toSeq)
84
+ .sort((a, b) => a.seq - b.seq);
85
+ },
86
+
87
+ /** Highest seq per host — the cursor a replica sends to ask for what it lacks. */
88
+ cursor() {
89
+ const out = {};
90
+ for (const k of adapter.keys(SEQ)) out[k.slice(SEQ.length)] = adapter.get(k);
91
+ return out;
92
+ },
93
+
94
+ /** Events a replica with this cursor has not seen. */
95
+ since(cursor = {}) {
96
+ return store.all()
97
+ .filter((e) => e.seq > (cursor[e.host] ?? -1))
98
+ .sort((a, b) => (a.host === b.host ? a.seq - b.seq : a.host < b.host ? -1 : 1));
99
+ },
100
+
101
+ /** Walk the causality chain backwards from an event. */
102
+ ancestry(id, seen = new Set()) {
103
+ const e = store.get(id);
104
+ if (!e || seen.has(id)) return [];
105
+ seen.add(id);
106
+ return [e, ...e.causes.flatMap((c) => store.ancestry(c, seen))];
107
+ },
108
+
109
+ stats: () => ({ events: adapter.keys(EV).length, bytes: adapter.size() }),
110
+ };
111
+ return store;
112
+ }
113
+
114
+ /**
115
+ * Content-addressed blob store. Dedupes by hash, so attaching the same excerpt a
116
+ * hundred times costs one copy — the rule that keeps the log from growing linearly in
117
+ * content.
118
+ *
119
+ * `digest` is injected: no ambient dependency on a crypto implementation, and tests stay
120
+ * deterministic. Defaults to SHA-256 via WebCrypto, present in browsers and Node >= 18.
121
+ */
122
+ export function createBlobStore(adapter = createMemoryAdapter(), { digest = sha256Hex } = {}) {
123
+ const B = 'b/';
124
+ const store = {
125
+ async put(content) {
126
+ const hash = await digest(content);
127
+ const key = B + hash;
128
+ if (adapter.get(key) === undefined) adapter.put(key, content);
129
+ return hash;
130
+ },
131
+ get: (hash) => adapter.get(B + hash),
132
+ has: (hash) => adapter.get(B + hash) !== undefined,
133
+
134
+ /**
135
+ * CRYPTO-SHRED. Drops the payload and leaves a tombstone, so "delete this meeting"
136
+ * can be honoured against an append-only log: the content is unrecoverable while
137
+ * every event that referenced it, and the causality chain, stay intact. Replay then
138
+ * reports verified-but-unavailable rather than substituting current content.
139
+ */
140
+ shred(hash) {
141
+ if (adapter.get(B + hash) === undefined) return false;
142
+ adapter.delete(B + hash);
143
+ adapter.put(`t/${hash}`, { shredded: true });
144
+ return true;
145
+ },
146
+ isShredded: (hash) => adapter.get(`t/${hash}`) !== undefined,
147
+
148
+ /** Resolve a Ref the way replay must: exact, shredded, or absent. */
149
+ lookup(ref) {
150
+ const value = store.get(ref.hash);
151
+ if (value === undefined) return null;
152
+ return { hash: ref.hash, value };
153
+ },
154
+
155
+ stats: () => ({
156
+ blobs: adapter.keys(B).length,
157
+ shredded: adapter.keys('t/').length,
158
+ bytes: adapter.size(),
159
+ }),
160
+ };
161
+ return store;
162
+ }
163
+
164
+ // Global WebCrypto: present in every browser and in Node from 19 onward. Callers on an
165
+ // older or unusual runtime inject their own `digest` rather than the package reaching
166
+ // for `node:crypto`, which would put a Node import in a browser-first module.
167
+ async function sha256Hex(content) {
168
+ const bytes = typeof content === 'string' ? new TextEncoder().encode(content) : content;
169
+ const buf = await globalThis.crypto.subtle.digest('SHA-256', bytes);
170
+ return `sha256:${[...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')}`;
171
+ }
package/tool-groups.js ADDED
@@ -0,0 +1,81 @@
1
+ // Tool groups as plugins — "here is a set of capabilities, and when to offer them".
2
+ //
3
+ // The extension assembles a turn's toolset from three hardcoded blocks: the user's own
4
+ // data, their MCP servers, and the page. Each block is the same shape — decide whether it
5
+ // applies, build a provider, collapse it behind a dispatcher — written out three times, so
6
+ // a fourth means editing a shared function and no other client can contribute one at all.
7
+ //
8
+ // The failure this prevents is not hypothetical. A duplicated search-engine list in two
9
+ // files produced a retired engine that kept reappearing in settings; two implementations of
10
+ // one decision disagree eventually. A registry makes that unrepresentable: there is one
11
+ // list, and it is the registrations.
12
+ //
13
+ // A group DECIDES and BUILDS; it does not know about the others. Ordering is explicit
14
+ // priority rather than call order, because "which tools does the model see first" should be
15
+ // a stated decision and not an accident of where someone added a line.
16
+
17
+ export class ToolGroupError extends Error {
18
+ constructor(code, message) { super(message); this.name = 'ToolGroupError'; this.code = code; }
19
+ }
20
+
21
+ /**
22
+ * @param applies (ctx) => boolean — cheap, synchronous, no side effects. Kept separate from
23
+ * `build` so "should this be offered" can be answered without paying to construct
24
+ * it: MCP construction connects to servers, and asking that question should not.
25
+ * @param build async (ctx) => provider | null. Returning null is normal (nothing
26
+ * configured), not an error.
27
+ */
28
+ export function defineToolGroup({ id, label, applies, build, priority = 0 }) {
29
+ if (!id) throw new ToolGroupError('BAD_GROUP', 'group.id required');
30
+ if (typeof build !== 'function') throw new ToolGroupError('BAD_GROUP', `group '${id}': build required`);
31
+ return Object.freeze({
32
+ id,
33
+ label: label || id,
34
+ priority,
35
+ applies: typeof applies === 'function' ? applies : () => true,
36
+ build,
37
+ });
38
+ }
39
+
40
+ export function createToolGroupRegistry() {
41
+ const groups = [];
42
+ return {
43
+ add(group) {
44
+ groups.push(group);
45
+ return () => { const i = groups.indexOf(group); if (i >= 0) groups.splice(i, 1); };
46
+ },
47
+
48
+ list: () => [...groups].sort((a, b) => b.priority - a.priority),
49
+
50
+ /**
51
+ * Build every group that applies, in priority order.
52
+ *
53
+ * Groups are built CONCURRENTLY because one of them connects to remote servers and
54
+ * serialising would add its latency to every turn — but the RESULT is re-sorted by
55
+ * priority, so the order the model sees never depends on which finished first.
56
+ *
57
+ * A group that throws is dropped with a report, not propagated: a broken MCP server
58
+ * must not cost the user their history tools. Same isolation rule as the source
59
+ * registry and the adapter registry, for the same reason.
60
+ */
61
+ async build(ctx, { onError = () => {}, admit = null } = {}) {
62
+ const eligible = this.list().filter((g) => {
63
+ // Admission is checked BEFORE `applies`, and both before `build`. A group the user
64
+ // switched off must not do its work and be discarded afterwards — for MCP that work
65
+ // is connecting to servers, which is what once made a first turn wait 45 seconds.
66
+ if (admit && !admit(g)) return false;
67
+ try { return !!g.applies(ctx); } catch (e) { onError(g.id, e); return false; }
68
+ });
69
+ const built = await Promise.all(eligible.map(async (g) => {
70
+ try { return { id: g.id, priority: g.priority, provider: await g.build(ctx) }; } catch (e) {
71
+ onError(g.id, e);
72
+ return null;
73
+ }
74
+ }));
75
+ return built
76
+ .filter((b) => b && b.provider)
77
+ .sort((a, b) => b.priority - a.priority)
78
+ .map((b) => ({ id: b.id, provider: b.provider }));
79
+ },
80
+ };
81
+ }
package/tool-need.js ADDED
@@ -0,0 +1,96 @@
1
+ // Does this turn need tools AT ALL — asked before any of them are built.
2
+ //
3
+ // Every turn was armed with the same equipment regardless of what was said. "hi" arrived at
4
+ // the model carrying a history dispatcher, an MCP dispatcher and ~1,200 tokens of rulebook
5
+ // explaining how to use them, and that cost more than the prompt itself. That is not only
6
+ // waste, it CHANGES THE ANSWER: a turn that carries tools requires a model that can call
7
+ // them (see requirementsFor), so a greeting eliminated every model without the tools
8
+ // capability and then paid a CLI agent two seconds to spawn a process in order to wave back.
9
+ //
10
+ // Equipment is not demand. The question "what does this turn need" has to be asked of the
11
+ // MESSAGE, before the toolset exists — which is what the router already does for model
12
+ // choice, from the same signals, for free.
13
+ //
14
+ // THE ERROR BIAS IS THE OPPOSITE OF THE ROUTER'S, which is why this is not simply
15
+ // `signals.smalltalk`. Mis-routing a turn produces a worse answer; withholding the history
16
+ // tools from "what did we decide in the standup" produces "I cannot access your meetings" —
17
+ // wrong, and the exact thing the tool system prompt exists to prevent. So this does not try
18
+ // to detect which turns need tools. It recognises the narrow class of turns that provably
19
+ // cannot — pleasantries, and nothing else — and arms everything otherwise.
20
+ //
21
+ // Recognised by VOCABULARY rather than by absence: every word must be a conversational
22
+ // move. An unknown word, a typo, a name, a question — anything at all — falls through to
23
+ // "arm the tools", which is the safe direction. `smalltalk` still has to agree, so the two
24
+ // definitions of trivial cannot drift apart.
25
+ //
26
+ // Class R: no model call, no network, no I/O. Reading a string.
27
+
28
+ import { signalsFrom } from './router.js';
29
+
30
+ // Greetings, thanks, acknowledgements, farewells — and the filler that attaches to them
31
+ // ("hey there", "ok got it", "thanks so much"). Deliberately small: every addition widens
32
+ // the set of turns that get no tools, so a word earns its place by being unable to appear
33
+ // in a request.
34
+ const PLEASANTRY = new Set([
35
+ 'hi', 'hii', 'hiya', 'hey', 'heya', 'hello', 'helo', 'yo', 'sup', 'howdy', 'greetings',
36
+ 'good', 'morning', 'afternoon', 'evening', 'night', 'day',
37
+ 'thanks', 'thank', 'thankyou', 'thx', 'tnx', 'ty', 'cheers', 'appreciated',
38
+ 'ok', 'okay', 'k', 'kk', 'got', 'sounds', 'perfect', 'great', 'cool', 'nice', 'awesome',
39
+ 'lol', 'haha', 'hah', 'hehe', 'nvm', 'yep', 'yeah', 'yes', 'no', 'nope', 'sure',
40
+ 'bye', 'goodbye', 'later', 'ya', 'cya', 'ciao', 'welcome', 'worries', 'problem', 'np',
41
+ 'please', 'there', 'again', 'all', 'everyone', 'team', 'friend', 'mate', 'buddy',
42
+ 'you', 'u', 'so', 'much', 'very', 'well', 'and', 'a', 'the',
43
+ ]);
44
+
45
+ // At most a short phrase. A long message built entirely from these words is not a greeting,
46
+ // it is something this rule does not understand — and not understanding means arm the tools.
47
+ const MAX_PLEASANTRY_WORDS = 6;
48
+
49
+ function isPleasantry(text) {
50
+ // Strip emoji, punctuation and digits: "hi!! 👋" is "hi". Anything left must be a word in
51
+ // the vocabulary.
52
+ const words = String(text || '')
53
+ .toLowerCase()
54
+ .replace(/[^\p{L}\s']/gu, ' ')
55
+ .split(/\s+/)
56
+ .filter(Boolean);
57
+ if (!words.length || words.length > MAX_PLEASANTRY_WORDS) return false;
58
+ return words.every((w) => PLEASANTRY.has(w));
59
+ }
60
+
61
+ /**
62
+ * @param request { text } | { messages } — the turn, as the router already reads it.
63
+ * @param signals precomputed signalsFrom(request), when the caller already has them.
64
+ * @param attachments anything the user attached; presence alone means there is material.
65
+ * @param explicit the user or a skill ASKED for tools this turn (MCP mode 'on', the
66
+ * /history hint, a running skill). Never second-guessed.
67
+ * @returns { tools, why } — `tools: false` means build nothing at all this turn.
68
+ */
69
+ export function toolNeedFor({ request = null, signals = null, attachments = [], explicit = false } = {}) {
70
+ if (explicit) return { tools: true, why: 'the turn asked for tools' };
71
+ // MATERIAL THE USER HANDED OVER — not the tab that happens to be open.
72
+ //
73
+ // The side panel auto-attaches the current page to every send, so "hi" on a search results
74
+ // page arrived carrying an attachment and armed the full toolset: three tools, ~2,300
75
+ // tokens and a page read, to say hello back. The open tab is METADATA about where the user
76
+ // is, not content they asked about; treating the two the same made the ambient page defeat
77
+ // this rule on every turn where it mattered.
78
+ //
79
+ // `auto` marks it. A page the user genuinely attached has no such mark and still counts —
80
+ // and a message that ASKS about the page ("summarise this") is not a pleasantry anyway, so
81
+ // it arms tools by the ordinary path rather than by what happens to be attached.
82
+ if ((attachments || []).some((a) => a && !a.auto)) {
83
+ return { tools: true, why: 'the turn carries an attachment' };
84
+ }
85
+
86
+ const text = String(request?.text ?? (request?.messages || []).map((m) => m?.content || '').join('\n'));
87
+ if (!isPleasantry(text)) return { tools: true, why: 'the request may need something fetched' };
88
+
89
+ // And the router has to agree it is trivial. Two definitions of "asks for nothing" that
90
+ // can disagree is the duplication this codebase keeps removing; requiring both means the
91
+ // stricter one always wins.
92
+ const sig = signals || signalsFrom(request || {});
93
+ if (!sig.smalltalk) return { tools: true, why: 'the request may need something fetched' };
94
+
95
+ return { tools: false, why: 'a greeting — nothing to look up' };
96
+ }