@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/LICENSE +168 -0
- package/README.md +183 -0
- package/adapters.js +83 -0
- package/capability.js +121 -0
- package/citations.js +79 -0
- package/event.js +170 -0
- package/harness.js +101 -0
- package/index.js +44 -0
- package/invariants.js +174 -0
- package/kernel.js +255 -0
- package/loop.js +132 -0
- package/manifest.js +107 -0
- package/mcp-errors.js +87 -0
- package/meeting-analyzers.js +83 -0
- package/order.js +78 -0
- package/package.json +85 -0
- package/ref.js +52 -0
- package/registry.js +240 -0
- package/route-graph.js +115 -0
- package/router.js +831 -0
- package/rules.js +142 -0
- package/search-engines.js +81 -0
- package/sources-retrieval.js +189 -0
- package/sources.js +256 -0
- package/store.js +171 -0
- package/tool-groups.js +81 -0
- package/tool-need.js +96 -0
- package/trajectory.js +509 -0
- package/upcast.js +37 -0
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
|
+
}
|