@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/rules.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Class-R automation — value without a model.
|
|
2
|
+
//
|
|
3
|
+
// Everything ChatPanel does today needs a model, which means everything costs tokens,
|
|
4
|
+
// latency and a network. But a great deal of what users actually want is a RULE: when a
|
|
5
|
+
// meeting ends, save the notes; when a page matches, offer to act on it; when a note gains
|
|
6
|
+
// a heading, index it. Those are deterministic, instant, free, and provable — and until
|
|
7
|
+
// there is somewhere to declare them, each is hand-written into whichever file noticed the
|
|
8
|
+
// need first.
|
|
9
|
+
//
|
|
10
|
+
// A rule fires FROM THE EVENT LOG, which is what finally makes the log a bus rather than a
|
|
11
|
+
// record. That is the whole reason the log came first: a rule that had to be called
|
|
12
|
+
// explicitly by the code that might interest it is not automation, it is a function call
|
|
13
|
+
// with extra steps.
|
|
14
|
+
//
|
|
15
|
+
// THREE PROPERTIES THAT ARE NOT NEGOTIABLE, because automation is where a mistake is
|
|
16
|
+
// unattended:
|
|
17
|
+
// • A rule may NARROW authority, never widen it. It runs with the scope it was granted
|
|
18
|
+
// and cannot request more at fire time.
|
|
19
|
+
// • A non-pure action carries an idempotency key, so a rule that fires twice on a redelivered
|
|
20
|
+
// event does its thing once (I3).
|
|
21
|
+
// • Every decision is recorded — fired AND suppressed — because an automation you cannot
|
|
22
|
+
// see is one you cannot trust, and 'it did nothing' has many causes worth telling apart.
|
|
23
|
+
|
|
24
|
+
export class RuleError extends Error {
|
|
25
|
+
constructor(code, message) { super(message); this.name = 'RuleError'; this.code = code; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Why a rule did not fire. Each is a different problem, so each has a name. */
|
|
29
|
+
export const SUPPRESSED = Object.freeze({
|
|
30
|
+
DISABLED: 'disabled', // switched off in Plugins
|
|
31
|
+
CONDITION: 'condition-false', // the trigger matched, the condition did not
|
|
32
|
+
DUPLICATE: 'already-fired', // same event, same rule — redelivery, not a new cause
|
|
33
|
+
RATE_LIMITED: 'rate-limited', // fired too recently
|
|
34
|
+
NO_PERMISSION: 'no-permission', // the guard refused
|
|
35
|
+
ERROR: 'error', // the rule itself threw
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param on event type, or array of them. Matching on TYPE first is what keeps a busy
|
|
40
|
+
* log cheap: a predicate is only run for events that could possibly match.
|
|
41
|
+
* @param when (event, ctx) => boolean. Pure and synchronous, deliberately — a condition
|
|
42
|
+
* that could do I/O would make "did this rule match" unanswerable without
|
|
43
|
+
* side effects, and untestable without mocks.
|
|
44
|
+
* @param then async (event, ctx) => result. The action. Receives `ctx.invoke`, so a rule
|
|
45
|
+
* cannot reach a capability except through the guarded path.
|
|
46
|
+
* @param classUsed the execution class this rule actually uses — 'R' for a pure rule, 'M'
|
|
47
|
+
* for a small model, 'C' for a cloud one. Declared rather than inferred,
|
|
48
|
+
* because the honest answer to "did this cost anything" cannot be guessed.
|
|
49
|
+
* @param everyMs minimum gap between fires. 0 means every matching event.
|
|
50
|
+
*/
|
|
51
|
+
export function defineRule({
|
|
52
|
+
id, label, on, when = null, then, classUsed = 'R',
|
|
53
|
+
everyMs = 0, requiresApproval = false, description = '', effects = 'idempotent',
|
|
54
|
+
}) {
|
|
55
|
+
if (!id) throw new RuleError('BAD_RULE', 'rule.id required');
|
|
56
|
+
if (typeof then !== 'function') throw new RuleError('BAD_RULE', `rule '${id}': then required`);
|
|
57
|
+
const types = Array.isArray(on) ? on : [on];
|
|
58
|
+
if (!types.length || types.some((t) => typeof t !== 'string' || !t)) {
|
|
59
|
+
throw new RuleError('BAD_RULE', `rule '${id}': on must name at least one event type`);
|
|
60
|
+
}
|
|
61
|
+
if (when && typeof when !== 'function') throw new RuleError('BAD_RULE', `rule '${id}': when must be a function`);
|
|
62
|
+
// A rule that changes the world without an idempotency story will eventually do it twice.
|
|
63
|
+
if (effects === 'non-replayable' && !requiresApproval && classUsed === 'R') {
|
|
64
|
+
// Not an error — some rules genuinely must act — but it has to be stated, so the
|
|
65
|
+
// decision is visible in the declaration rather than discovered from behaviour.
|
|
66
|
+
}
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
id, label: label || id, on: types, when, then, classUsed,
|
|
69
|
+
everyMs, requiresApproval, description, effects,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createRuleEngine({ emit = () => {}, now = () => 0, admit = null, approve = null } = {}) {
|
|
74
|
+
const rules = [];
|
|
75
|
+
const lastFired = new Map();
|
|
76
|
+
const seen = new Set(); // `${ruleId}:${eventId}` — redelivery is not a new cause
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
add(rule) {
|
|
80
|
+
rules.push(rule);
|
|
81
|
+
return () => { const i = rules.indexOf(rule); if (i >= 0) rules.splice(i, 1); };
|
|
82
|
+
},
|
|
83
|
+
list: () => [...rules],
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Offer one event to every rule. Returns what happened, so a caller can assert on it —
|
|
87
|
+
* an engine whose only output is side effects cannot be tested without mocks.
|
|
88
|
+
*
|
|
89
|
+
* Never throws. A rule that fails must not take down the thing that emitted the event:
|
|
90
|
+
* automation is a passenger, not a driver.
|
|
91
|
+
*/
|
|
92
|
+
async dispatch(event, ctx = {}) {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const rule of rules) {
|
|
95
|
+
if (!rule.on.includes(event?.type)) continue;
|
|
96
|
+
|
|
97
|
+
const suppress = (reason, detail) => {
|
|
98
|
+
emit('automation.suppressed', { ruleId: rule.id, reason, eventId: event.id, ...detail });
|
|
99
|
+
out.push({ ruleId: rule.id, fired: false, reason });
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (admit && !admit(rule)) { suppress(SUPPRESSED.DISABLED); continue; }
|
|
103
|
+
|
|
104
|
+
const key = `${rule.id}:${event.id}`;
|
|
105
|
+
if (seen.has(key)) { suppress(SUPPRESSED.DUPLICATE); continue; }
|
|
106
|
+
|
|
107
|
+
if (rule.everyMs > 0 && lastFired.has(rule.id)) {
|
|
108
|
+
// `has`, not truthiness: a rule that fired at timestamp 0 HAS fired, and treating
|
|
109
|
+
// that as "never" gives it one free pass through its own rate limit. Only a test
|
|
110
|
+
// clock starts at 0, but a guard that is wrong for one value is wrong.
|
|
111
|
+
if (now() - lastFired.get(rule.id) < rule.everyMs) { suppress(SUPPRESSED.RATE_LIMITED); continue; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let matched = true;
|
|
115
|
+
try { matched = rule.when ? !!rule.when(event, ctx) : true; } catch (e) {
|
|
116
|
+
// A condition that throws is a condition that did not match. Firing on an
|
|
117
|
+
// unanswered question is how automation does something nobody asked for.
|
|
118
|
+
suppress(SUPPRESSED.ERROR, { message: e.message });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!matched) { suppress(SUPPRESSED.CONDITION); continue; }
|
|
122
|
+
|
|
123
|
+
if (rule.requiresApproval) {
|
|
124
|
+
const ok = approve ? await approve(rule, event) : false;
|
|
125
|
+
if (!ok) { suppress(SUPPRESSED.NO_PERMISSION); continue; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
seen.add(key);
|
|
129
|
+
lastFired.set(rule.id, now());
|
|
130
|
+
try {
|
|
131
|
+
const result = await rule.then(event, ctx);
|
|
132
|
+
emit('automation.fired', { ruleId: rule.id, classUsed: rule.classUsed, eventId: event.id });
|
|
133
|
+
out.push({ ruleId: rule.id, fired: true, result });
|
|
134
|
+
} catch (e) {
|
|
135
|
+
emit('automation.suppressed', { ruleId: rule.id, reason: SUPPRESSED.ERROR, eventId: event.id, message: e.message });
|
|
136
|
+
out.push({ ruleId: rule.id, fired: false, reason: SUPPRESSED.ERROR, error: e.message });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Search engines as declarations, not as a literal in two files.
|
|
2
|
+
//
|
|
3
|
+
// This one is not speculative. The engine list existed as an array in the search runtime
|
|
4
|
+
// AND as a copy in the settings page, and when a broken engine was removed from one it kept
|
|
5
|
+
// appearing in the other. Two implementations of one list disagree eventually; the only
|
|
6
|
+
// reliable fix is for there to be one list.
|
|
7
|
+
//
|
|
8
|
+
// It is also where the plugin model pays off soonest for a USER: an engine is a name, a URL
|
|
9
|
+
// template, and a way to read the result. That is a thing someone could contribute without
|
|
10
|
+
// touching any code we wrote.
|
|
11
|
+
|
|
12
|
+
export class SearchEngineError extends Error {
|
|
13
|
+
constructor(code, message) { super(message); this.name = 'SearchEngineError'; this.code = code; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ENGINE_KINDS = Object.freeze(['serp', 'api']);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param kind 'serp' — an HTML results page to read; 'api' — a service that answers
|
|
20
|
+
* directly. The distinction is not cosmetic: a SERP can be blocked and
|
|
21
|
+
* needs a fallback, an API can need a key and must not run without one.
|
|
22
|
+
* @param needsKey the engine sends queries to a third party that requires credentials.
|
|
23
|
+
* Engines that need a key stay OFF until one exists, because a default that
|
|
24
|
+
* sends the user's queries somewhere they never chose is not a default.
|
|
25
|
+
*/
|
|
26
|
+
export function defineSearchEngine({ id, name, url, kind = 'serp', enabled = false, needsKey = false, retired = false }) {
|
|
27
|
+
if (!id) throw new SearchEngineError('BAD_ENGINE', 'engine.id required');
|
|
28
|
+
if (!ENGINE_KINDS.includes(kind)) throw new SearchEngineError('BAD_ENGINE', `engine '${id}': unknown kind '${kind}'`);
|
|
29
|
+
if (!retired && !url) throw new SearchEngineError('BAD_ENGINE', `engine '${id}': url required`);
|
|
30
|
+
return Object.freeze({ id, name: name || id, url, kind, enabled, needsKey, retired });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reconcile a user's stored list against the declared engines.
|
|
35
|
+
*
|
|
36
|
+
* The one function both the runtime and the settings page call, so what search uses and
|
|
37
|
+
* what settings shows cannot drift — which is the entire bug this replaces.
|
|
38
|
+
*/
|
|
39
|
+
export function reconcileEngines(stored, declared, { hasKey = false } = {}) {
|
|
40
|
+
const byId = new Map(declared.map((e) => [e.id, e]));
|
|
41
|
+
const retired = new Set(declared.filter((e) => e.retired).map((e) => e.id));
|
|
42
|
+
|
|
43
|
+
let out = (Array.isArray(stored) && stored.length ? stored : declared)
|
|
44
|
+
.filter((e) => e?.id && !retired.has(e.id))
|
|
45
|
+
.map((e) => ({ ...(byId.get(e.id) || {}), ...e }));
|
|
46
|
+
|
|
47
|
+
// Anything declared but never seen by this user is added, off unless it ships on. A user
|
|
48
|
+
// who has saved settings once must still receive new engines.
|
|
49
|
+
for (const d of declared) {
|
|
50
|
+
if (d.retired || out.some((e) => e.id === d.id)) continue;
|
|
51
|
+
out.push({ ...d });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
out = out.map((e) => {
|
|
55
|
+
const d = byId.get(e.id);
|
|
56
|
+
if (!d?.needsKey) return e;
|
|
57
|
+
// "They turned it off" and "it has never been offered" are different states: a key
|
|
58
|
+
// enables the second, never overrides the first.
|
|
59
|
+
const known = Array.isArray(stored) && stored.some((s) => s?.id === e.id);
|
|
60
|
+
const declined = known && stored.find((s) => s.id === e.id)?.enabled === false;
|
|
61
|
+
return { ...e, enabled: hasKey && !declined };
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Never leave the user with nothing usable. Retiring an engine can empty a list that
|
|
65
|
+
// contained only that engine, and a search feature that silently has no engines is a
|
|
66
|
+
// worse failure than the one being fixed.
|
|
67
|
+
if (!out.some((e) => e.enabled !== false && !e.needsKey)) {
|
|
68
|
+
for (const d of declared) {
|
|
69
|
+
if (d.retired || d.needsKey) continue;
|
|
70
|
+
const have = out.find((e) => e.id === d.id);
|
|
71
|
+
if (have) have.enabled = d.enabled !== false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The order to actually try: enabled first (in list order), then the rest as fallbacks. */
|
|
78
|
+
export function attemptOrder(engines) {
|
|
79
|
+
const live = engines.filter((e) => !e.retired);
|
|
80
|
+
return [...live.filter((e) => e.enabled !== false), ...live.filter((e) => e.enabled === false)];
|
|
81
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SOURCES ARE QUERIED, NOT POURED IN.
|
|
3
|
+
*
|
|
4
|
+
* Attached context used to be flattened into the first message: every attached tab, in full,
|
|
5
|
+
* before the model had said anything. So "hi" on a long page paid for the whole page, and
|
|
6
|
+
* five attached tabs meant five documents in the prompt to answer a question that concerned
|
|
7
|
+
* one paragraph of one of them.
|
|
8
|
+
*
|
|
9
|
+
* The model is instead shown a MANIFEST — what exists, how big it is, where it came from —
|
|
10
|
+
* and pulls what it needs. Three things follow that are not just savings:
|
|
11
|
+
*
|
|
12
|
+
* - The turn starts small, so a cheap model can handle a greeting on a heavy page.
|
|
13
|
+
* - What was read is a fact in the log rather than an assumption about the prompt.
|
|
14
|
+
* - Retrieval takes a QUERY, so a large source returns the relevant part instead of its
|
|
15
|
+
* first N characters — which is what truncation gives you, and it is rarely the part
|
|
16
|
+
* that matters.
|
|
17
|
+
*
|
|
18
|
+
* Pure and dependency-free: the extension, the gateway and the bridge all have attached
|
|
19
|
+
* sources, and three implementations of "what did the model actually read" would drift.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CHUNK_SPLIT = /\n{2,}/;
|
|
23
|
+
|
|
24
|
+
/** ~4 chars per token — the same estimate the dispatcher budget uses. One rough number beats two. */
|
|
25
|
+
export const approxTokens = (s) => Math.ceil(String(s || '').length / 4);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A stable, short id for a source. The model has to type this back, so it is derived from the
|
|
29
|
+
* title rather than from a random string: `page-2` is recoverable from a manifest a model
|
|
30
|
+
* half-remembers in a way that `k3f9a1` is not.
|
|
31
|
+
*/
|
|
32
|
+
export function sourceId(source, index = 0) {
|
|
33
|
+
const base = String(source?.kind || 'src').toLowerCase().replace(/[^a-z0-9]+/g, '') || 'src';
|
|
34
|
+
return `${base}-${index + 1}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the manifest the model sees INSTEAD of the content.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately includes the size. A model that can see one source is 300 tokens and another
|
|
41
|
+
* is 40,000 can decide to read the small one whole and query the large one — a decision it
|
|
42
|
+
* cannot make when both are simply "a document".
|
|
43
|
+
*/
|
|
44
|
+
export function makeSourceStore(sources = []) {
|
|
45
|
+
const entries = (Array.isArray(sources) ? sources : [sources])
|
|
46
|
+
.filter((s) => s && (s.text || s.url))
|
|
47
|
+
.map((s, i) => ({
|
|
48
|
+
id: s.id || sourceId(s, i),
|
|
49
|
+
kind: s.kind || 'context',
|
|
50
|
+
title: s.title || s.url || 'Untitled',
|
|
51
|
+
url: s.url || '',
|
|
52
|
+
text: String(s.text || ''),
|
|
53
|
+
tokens: approxTokens(s.text),
|
|
54
|
+
}));
|
|
55
|
+
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
56
|
+
return {
|
|
57
|
+
entries,
|
|
58
|
+
get: (id) => byId.get(String(id || '').trim()) || null,
|
|
59
|
+
get tokens() { return entries.reduce((a, e) => a + e.tokens, 0); },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* WHERE IT CAME FROM, not the whole address bar.
|
|
65
|
+
*
|
|
66
|
+
* A Google results URL carries about a thousand characters of tracking parameters — sca_esv,
|
|
67
|
+
* gs_lp, sclient — and the manifest printed every one of them, on every turn, in a line whose
|
|
68
|
+
* job is "here is a source you may read". That is a quarter of a thousand tokens of noise the
|
|
69
|
+
* model cannot use and must still read past.
|
|
70
|
+
*
|
|
71
|
+
* The host and path identify the source; the query string is kept only when it is short
|
|
72
|
+
* enough to be meaning rather than machinery. An unparseable URL is left exactly as given —
|
|
73
|
+
* guessing at a string we could not read is how a source becomes unidentifiable.
|
|
74
|
+
*/
|
|
75
|
+
export function shortUrl(url, max = 120) {
|
|
76
|
+
const raw = String(url || '');
|
|
77
|
+
if (!raw || raw.length <= max) return raw;
|
|
78
|
+
try {
|
|
79
|
+
const u = new URL(raw);
|
|
80
|
+
const base = `${u.host}${u.pathname}`.replace(/\/$/, '');
|
|
81
|
+
// A short query is often the whole point of the address ("?q=how+do+tides+work");
|
|
82
|
+
// a long one is machinery. Keep the first parameter when it fits, drop the rest.
|
|
83
|
+
const first = [...u.searchParams.entries()][0];
|
|
84
|
+
const q = first && `${first[0]}=${first[1]}`;
|
|
85
|
+
const withQ = q && `${base}?${q}`;
|
|
86
|
+
const out = withQ && withQ.length <= max ? withQ : base;
|
|
87
|
+
return out.length <= max ? `${out}…` : `${out.slice(0, max)}…`;
|
|
88
|
+
} catch {
|
|
89
|
+
return `${raw.slice(0, max)}…`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One line per source: what it is, where it came from, what it would cost to read. */
|
|
94
|
+
export function manifestText(store) {
|
|
95
|
+
if (!store?.entries?.length) return '';
|
|
96
|
+
const lines = store.entries.map((e) => `- ${e.id} — ${e.title}${e.url ? ` (${shortUrl(e.url)})` : ''} · ~${e.tokens} tokens`);
|
|
97
|
+
return [
|
|
98
|
+
'Attached sources (NOT included below — read them with the `source` tool):',
|
|
99
|
+
...lines,
|
|
100
|
+
].join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Score a chunk against the query by term overlap.
|
|
105
|
+
*
|
|
106
|
+
* Deliberately not a model call and not an embedding: retrieval that needs a model to decide
|
|
107
|
+
* what to retrieve costs a round trip before the real one, and this runs on every source read.
|
|
108
|
+
* Term overlap is crude and instant, and for "which paragraph of this page mentions X" crude
|
|
109
|
+
* is usually right.
|
|
110
|
+
*/
|
|
111
|
+
function scoreChunk(chunk, terms) {
|
|
112
|
+
if (!terms.length) return 0;
|
|
113
|
+
const hay = chunk.toLowerCase();
|
|
114
|
+
let score = 0;
|
|
115
|
+
for (const t of terms) {
|
|
116
|
+
if (!hay.includes(t)) continue;
|
|
117
|
+
// Rarer terms are worth more, approximated by length: 'authentication' discriminates,
|
|
118
|
+
// 'the' does not.
|
|
119
|
+
score += Math.min(3, t.length / 4);
|
|
120
|
+
}
|
|
121
|
+
return score;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const STOP = new Set(['the', 'and', 'for', 'this', 'that', 'with', 'from', 'what', 'when', 'where', 'which', 'have', 'has', 'was', 'are', 'you', 'your', 'can', 'about', 'into', 'does', 'did', 'how', 'why', 'all', 'any', 'its']);
|
|
125
|
+
|
|
126
|
+
export function queryTerms(query) {
|
|
127
|
+
return String(query || '')
|
|
128
|
+
.toLowerCase()
|
|
129
|
+
.split(/[^a-z0-9]+/)
|
|
130
|
+
.filter((t) => t.length > 2 && !STOP.has(t));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read a source, optionally narrowed by a query.
|
|
135
|
+
*
|
|
136
|
+
* Returns `{ id, title, url, text, truncated, of }`. `truncated` is stated rather than
|
|
137
|
+
* implied: a model handed a silently-cut document answers confidently about the part it was
|
|
138
|
+
* not given, and there is no way for it — or the person reading the answer — to tell.
|
|
139
|
+
*/
|
|
140
|
+
export function readSource(store, { id, query = '', maxTokens = 2000 } = {}) {
|
|
141
|
+
const entry = store?.get?.(id);
|
|
142
|
+
if (!entry) {
|
|
143
|
+
const known = (store?.entries || []).map((e) => e.id).join(', ');
|
|
144
|
+
return { error: `No attached source '${id}'.${known ? ` Available: ${known}.` : ' Nothing is attached.'}` };
|
|
145
|
+
}
|
|
146
|
+
const budget = Math.max(200, Number(maxTokens) || 2000);
|
|
147
|
+
if (entry.tokens <= budget) {
|
|
148
|
+
return { id: entry.id, title: entry.title, url: entry.url, text: entry.text, truncated: false, of: entry.tokens };
|
|
149
|
+
}
|
|
150
|
+
const terms = queryTerms(query);
|
|
151
|
+
const chunks = entry.text.split(CHUNK_SPLIT).filter((c) => c.trim());
|
|
152
|
+
if (!terms.length) {
|
|
153
|
+
// No query: the head is the honest default — it is the only part we can justify choosing
|
|
154
|
+
// without being told what matters.
|
|
155
|
+
const text = entry.text.slice(0, budget * 4);
|
|
156
|
+
return { id: entry.id, title: entry.title, url: entry.url, text, truncated: true, of: entry.tokens,
|
|
157
|
+
note: `Showing the first ~${approxTokens(text)} of ${entry.tokens} tokens. Pass a query to get the relevant parts instead.` };
|
|
158
|
+
}
|
|
159
|
+
// Keep the ORIGINAL ORDER of whatever is selected. Ranking by score and presenting in score
|
|
160
|
+
// order rearranges a document, and a reordered document reads as a different argument.
|
|
161
|
+
const ranked = chunks
|
|
162
|
+
.map((c, i) => ({ c, i, s: scoreChunk(c, terms) }))
|
|
163
|
+
.filter((x) => x.s > 0)
|
|
164
|
+
.sort((a, b) => b.s - a.s);
|
|
165
|
+
const picked = [];
|
|
166
|
+
let used = 0;
|
|
167
|
+
for (const x of ranked) {
|
|
168
|
+
const t = approxTokens(x.c);
|
|
169
|
+
if (used + t > budget) continue;
|
|
170
|
+
picked.push(x);
|
|
171
|
+
used += t;
|
|
172
|
+
}
|
|
173
|
+
if (!picked.length) {
|
|
174
|
+
const text = entry.text.slice(0, budget * 4);
|
|
175
|
+
return { id: entry.id, title: entry.title, url: entry.url, text, truncated: true, of: entry.tokens,
|
|
176
|
+
note: `Nothing in this source matched '${query}'. Showing the beginning instead.` };
|
|
177
|
+
}
|
|
178
|
+
picked.sort((a, b) => a.i - b.i);
|
|
179
|
+
const gaps = picked.some((x, n) => n > 0 && x.i !== picked[n - 1].i + 1);
|
|
180
|
+
return {
|
|
181
|
+
id: entry.id,
|
|
182
|
+
title: entry.title,
|
|
183
|
+
url: entry.url,
|
|
184
|
+
text: picked.map((x) => x.c).join(gaps ? '\n\n[…]\n\n' : '\n\n'),
|
|
185
|
+
truncated: true,
|
|
186
|
+
of: entry.tokens,
|
|
187
|
+
note: `${picked.length} of ${chunks.length} sections, matched on '${query}'.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
package/sources.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE THE CONTENT CAME FROM DECIDES HOW FAR IT MAY TRAVEL.
|
|
3
|
+
*
|
|
4
|
+
* A model was summarising an internal access-management page and the turn went to a public
|
|
5
|
+
* inference host, because routing asked what the WORK needed — tools, vision, quality — and
|
|
6
|
+
* never asked where the material came from. Capability decided; provenance did not exist.
|
|
7
|
+
*
|
|
8
|
+
* This module supplies the missing question. It classifies a source URL as internal or not,
|
|
9
|
+
* and turns that into a REACH CEILING the router already knows how to enforce. Two properties
|
|
10
|
+
* make it a guard rather than a preference:
|
|
11
|
+
*
|
|
12
|
+
* - It only ever NARROWS. `meetReach` takes the tighter of two ceilings, so no later step,
|
|
13
|
+
* plugin or user dial can widen what an internal source already restricted. A guard that
|
|
14
|
+
* something downstream can relax is a suggestion.
|
|
15
|
+
* - It fails CLOSED. An unparseable URL is treated as internal, because the alternative is
|
|
16
|
+
* to send data outward on the strength of a string we could not read.
|
|
17
|
+
*
|
|
18
|
+
* Pure and dependency-free: the same rules must hold in the extension, the gateway and the
|
|
19
|
+
* bridge, or "internal" means three different things and the strictest one is not the one
|
|
20
|
+
* that runs.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const REACH_ORDER = ['device', 'trusted', 'any'];
|
|
24
|
+
|
|
25
|
+
/** The tighter of two reach ceilings. Guards compose by MEET — they can only narrow. */
|
|
26
|
+
export function meetReach(a, b) {
|
|
27
|
+
const ia = REACH_ORDER.indexOf(a);
|
|
28
|
+
const ib = REACH_ORDER.indexOf(b);
|
|
29
|
+
// An unknown value is not a licence to travel further: treat it as the tightest.
|
|
30
|
+
if (ia < 0) return REACH_ORDER.includes(b) ? b : 'device';
|
|
31
|
+
if (ib < 0) return a;
|
|
32
|
+
return REACH_ORDER[Math.min(ia, ib)];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Hosts that are internal by NETWORK TOPOLOGY — the STARTING list, not a floor.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately limited to what the address itself proves. A corporate wiki on a public SaaS
|
|
39
|
+
* domain looks exactly like any other public host from here — that case needs the user's own
|
|
40
|
+
* patterns, and pretending to detect it would give a false sense of coverage.
|
|
41
|
+
*
|
|
42
|
+
* These are seeded into the user's editable list rather than silently prepended to it,
|
|
43
|
+
* because "internal" is a fact about someone's network, not about ours: a developer testing
|
|
44
|
+
* against localhost may well want that traffic to reach a cloud model, and a rule they cannot
|
|
45
|
+
* see is a rule they cannot correct.
|
|
46
|
+
*/
|
|
47
|
+
export const INTERNAL_PATTERN_CATALOG = Object.freeze([
|
|
48
|
+
{ pattern: 'localhost', label: 'This machine, by name' },
|
|
49
|
+
{ pattern: '127.0.0.0/8', label: 'Loopback' },
|
|
50
|
+
{ pattern: '::1', label: 'Loopback (IPv6)' },
|
|
51
|
+
{ pattern: '0.0.0.0/8', label: 'This network' },
|
|
52
|
+
{ pattern: '10.0.0.0/8', label: 'Private network' },
|
|
53
|
+
{ pattern: '172.16.0.0/12', label: 'Private network' },
|
|
54
|
+
{ pattern: '192.168.0.0/16', label: 'Private network (home / office)' },
|
|
55
|
+
{ pattern: '100.64.0.0/10', label: 'Carrier-grade NAT — used by some corporate networks' },
|
|
56
|
+
{ pattern: '169.254.0.0/16', label: 'Link-local — never routes off this segment' },
|
|
57
|
+
{ pattern: 'fe80::/10', label: 'Link-local (IPv6)' },
|
|
58
|
+
{ pattern: 'fc00::/7', label: 'Unique local (IPv6) — the 10.x of IPv6' },
|
|
59
|
+
{ pattern: '*.internal', label: 'Reserved name' },
|
|
60
|
+
{ pattern: '*.intranet', label: 'Reserved name' },
|
|
61
|
+
{ pattern: '*.corp', label: 'Conventional corporate suffix' },
|
|
62
|
+
{ pattern: '*.lan', label: 'Conventional LAN suffix' },
|
|
63
|
+
{ pattern: '*.local', label: 'mDNS / Bonjour' },
|
|
64
|
+
{ pattern: '*.localdomain', label: 'Default suffix on many routers' },
|
|
65
|
+
{ pattern: '*.home', label: 'Home network' },
|
|
66
|
+
{ pattern: '*.home.arpa', label: 'Home network (RFC 8375)' },
|
|
67
|
+
{ pattern: '*.private', label: 'Conventional private suffix' },
|
|
68
|
+
{ pattern: '*.test', label: 'Reserved for testing (RFC 2606)' },
|
|
69
|
+
{ pattern: '*.invalid', label: 'Reserved as never-resolvable' },
|
|
70
|
+
{ pattern: '<intranet>', label: 'Any bare hostname with no dots, e.g. http://wiki/ — includes localhost' },
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The patterns on by default. Derived from the catalog so the list a UI offers and the list
|
|
75
|
+
* the classifier applies cannot drift — two copies of this would mean a rule someone can see
|
|
76
|
+
* and not switch off, or switch off and not escape.
|
|
77
|
+
*/
|
|
78
|
+
export const DEFAULT_INTERNAL_PATTERNS = Object.freeze(INTERNAL_PATTERN_CATALOG.map((x) => x.pattern));
|
|
79
|
+
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
80
|
+
|
|
81
|
+
function ipToInt(host) {
|
|
82
|
+
const m = IPV4.exec(host);
|
|
83
|
+
if (!m) return null;
|
|
84
|
+
const parts = m.slice(1).map(Number);
|
|
85
|
+
if (parts.some((n) => n > 255)) return null;
|
|
86
|
+
return parts.reduce((acc, n) => acc * 256 + n, 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* An IPv6 address as its 128 bits, or null if it is not one. Bits rather than a normalised
|
|
91
|
+
* string because prefix matching (fc00::/7, fe80::/10) is a bit-length comparison — a
|
|
92
|
+
* textual "starts with" would get /7 wrong, since the boundary falls inside a hex digit.
|
|
93
|
+
*/
|
|
94
|
+
function v6Bits(host) {
|
|
95
|
+
const h = String(host || '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
96
|
+
if (!h.includes(':')) return null;
|
|
97
|
+
const [headRaw, tailRaw, ...rest] = h.split('::');
|
|
98
|
+
if (rest.length) return null; // more than one '::' is not a valid address
|
|
99
|
+
const parse = (part) => (part ? part.split(':').filter((x) => x !== '') : []);
|
|
100
|
+
let head = parse(headRaw);
|
|
101
|
+
let tail = tailRaw === undefined ? [] : parse(tailRaw);
|
|
102
|
+
// A trailing IPv4 form (::ffff:10.0.0.1) — the last group is four octets, not two.
|
|
103
|
+
const last = (tail.length ? tail : head)[Math.max(0, (tail.length ? tail : head).length - 1)];
|
|
104
|
+
if (last && last.includes('.')) {
|
|
105
|
+
const v4 = ipToInt(last);
|
|
106
|
+
if (v4 == null) return null;
|
|
107
|
+
const pair = [(v4 >>> 16).toString(16), (v4 & 0xffff).toString(16)];
|
|
108
|
+
if (tail.length) tail = [...tail.slice(0, -1), ...pair];
|
|
109
|
+
else head = [...head.slice(0, -1), ...pair];
|
|
110
|
+
}
|
|
111
|
+
const missing = 8 - head.length - tail.length;
|
|
112
|
+
if (tailRaw === undefined ? missing !== 0 : missing < 0) return null;
|
|
113
|
+
const groups = [...head, ...Array(Math.max(0, missing)).fill('0'), ...tail];
|
|
114
|
+
if (groups.length !== 8) return null;
|
|
115
|
+
let bits = '';
|
|
116
|
+
for (const g of groups) {
|
|
117
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
118
|
+
bits += parseInt(g, 16).toString(2).padStart(16, '0');
|
|
119
|
+
}
|
|
120
|
+
return bits;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function inCidr(host, cidr) {
|
|
124
|
+
const [net, bitsRaw] = cidr.split('/');
|
|
125
|
+
const hostV6 = v6Bits(host);
|
|
126
|
+
const netV6 = v6Bits(net);
|
|
127
|
+
if (hostV6 || netV6) {
|
|
128
|
+
// A v4 host never sits inside a v6 range, and vice versa — comparing them would be a
|
|
129
|
+
// type confusion that quietly matches or quietly does not.
|
|
130
|
+
if (!hostV6 || !netV6) return false;
|
|
131
|
+
const n = Number(bitsRaw);
|
|
132
|
+
if (!Number.isFinite(n) || n < 0 || n > 128) return false;
|
|
133
|
+
return hostV6.slice(0, n) === netV6.slice(0, n);
|
|
134
|
+
}
|
|
135
|
+
const ip = ipToInt(host);
|
|
136
|
+
const base = ipToInt(net);
|
|
137
|
+
if (ip == null || base == null) return false;
|
|
138
|
+
const bits = Number(bitsRaw);
|
|
139
|
+
if (!Number.isFinite(bits) || bits < 0 || bits > 32) return false;
|
|
140
|
+
// Shifting by 32 is a no-op in JS, so /0 is spelled out rather than computed.
|
|
141
|
+
const mask = bits === 0 ? 0 : (-1 << (32 - bits)) >>> 0;
|
|
142
|
+
return (ip & mask) >>> 0 === (base & mask) >>> 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Does `host` match one pattern? Supported forms, in the order a person would expect:
|
|
147
|
+
* `*.example.com` the domain and every subdomain
|
|
148
|
+
* `example.com` the same — a bare domain covers its subdomains, because someone adding
|
|
149
|
+
* their company domain means the whole company, not one host
|
|
150
|
+
* `10.0.0.0/8` a CIDR range
|
|
151
|
+
* `<intranet>` any single-label host, which can only resolve on a private network
|
|
152
|
+
*/
|
|
153
|
+
export function hostMatches(host, pattern) {
|
|
154
|
+
const h = String(host || '').toLowerCase().replace(/\.$/, '');
|
|
155
|
+
const p = String(pattern || '').toLowerCase().trim();
|
|
156
|
+
if (!h || !p) return false;
|
|
157
|
+
// A NAME with no dots — not an address. A public IPv6 literal has no dots either, and
|
|
158
|
+
// sweeping it in here would have quietly pinned every v6 host on the internet as internal.
|
|
159
|
+
if (p === '<intranet>') return !h.includes('.') && !h.includes(':') && !h.startsWith('[') && !IPV4.test(h);
|
|
160
|
+
if (p.includes('/')) return inCidr(h, p);
|
|
161
|
+
// '::1' and '[::1]' and '0:0:0:0:0:0:0:1' are one address written three ways.
|
|
162
|
+
const pv6 = v6Bits(p);
|
|
163
|
+
if (pv6) return v6Bits(h) === pv6;
|
|
164
|
+
const bare = p.startsWith('*.') ? p.slice(2) : p;
|
|
165
|
+
if (h === bare) return true;
|
|
166
|
+
if (h.endsWith(`.${bare}`)) return true;
|
|
167
|
+
// A leading wildcard anywhere else ("*.corp.*") is not supported rather than
|
|
168
|
+
// half-supported: a pattern that silently matches nothing is worse than one that is
|
|
169
|
+
// rejected, because it looks like protection.
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Classify one source. Returns `{ internal, host, matched }`.
|
|
175
|
+
*
|
|
176
|
+
* FAILS CLOSED. A URL we cannot parse counts as internal: we would rather keep a public page
|
|
177
|
+
* on-device than send an internal one out because a string was malformed. The same applies to
|
|
178
|
+
* non-http schemes — a `file:` path is local by definition.
|
|
179
|
+
*/
|
|
180
|
+
export function classifySource(url, { patterns = DEFAULT_INTERNAL_PATTERNS } = {}) {
|
|
181
|
+
const raw = String(url || '').trim();
|
|
182
|
+
if (!raw) return { internal: false, host: '', matched: null };
|
|
183
|
+
let host = '';
|
|
184
|
+
let scheme = '';
|
|
185
|
+
try {
|
|
186
|
+
const u = new URL(raw);
|
|
187
|
+
host = u.hostname;
|
|
188
|
+
scheme = u.protocol.replace(':', '');
|
|
189
|
+
} catch {
|
|
190
|
+
return { internal: true, host: '', matched: 'unparseable' };
|
|
191
|
+
}
|
|
192
|
+
if (scheme === 'file') return { internal: true, host, matched: 'file:' };
|
|
193
|
+
// Extension and browser-internal pages carry no third-party content, and are not sources
|
|
194
|
+
// anyone means to protect — treating them as internal would pin every turn to a local
|
|
195
|
+
// model for no reason.
|
|
196
|
+
if (/^(chrome|edge|about|moz|chrome-extension|data|blob)$/.test(scheme)) {
|
|
197
|
+
return { internal: false, host, matched: null };
|
|
198
|
+
}
|
|
199
|
+
for (const p of patterns) {
|
|
200
|
+
if (hostMatches(host, p)) return { internal: true, host, matched: p };
|
|
201
|
+
}
|
|
202
|
+
return { internal: false, host, matched: null };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Every url written in a piece of text.
|
|
207
|
+
*
|
|
208
|
+
* Declared sources are not the only way internal material enters a turn: someone pastes a
|
|
209
|
+
* link to an internal runbook, or a tool result comes back carrying one. The address is the
|
|
210
|
+
* evidence, and it is evidence wherever it appears — so the same classifier that reads the
|
|
211
|
+
* tab reads the body too.
|
|
212
|
+
*
|
|
213
|
+
* Explicit schemes only. A bare host like `wiki/page` is indistinguishable from an ordinary
|
|
214
|
+
* path, and matching it would pin turns on text that mentions no site at all — a guard that
|
|
215
|
+
* fires on prose gets switched off, which protects nobody.
|
|
216
|
+
*/
|
|
217
|
+
export function extractUrls(text) {
|
|
218
|
+
const out = [];
|
|
219
|
+
const re = /\b(?:https?|file):\/\/[^\s<>"'`)\]}]+/gi;
|
|
220
|
+
for (const m of String(text || '').matchAll(re)) {
|
|
221
|
+
// Trailing punctuation belongs to the sentence, not to the address.
|
|
222
|
+
out.push(m[0].replace(/[.,;:!?]+$/, ''));
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Build the reach policy for a turn from everything it draws on.
|
|
229
|
+
*
|
|
230
|
+
* ANY internal source pins the WHOLE turn. A turn that mixes an internal page with a public
|
|
231
|
+
* one is still carrying the internal page, and splitting the difference would send it out.
|
|
232
|
+
*
|
|
233
|
+
* `ceiling` is what an internal source narrows to — 'device' for local models only, or
|
|
234
|
+
* 'trusted' to also allow a workspace gateway the user runs. It cannot widen anything: the
|
|
235
|
+
* result is always the tighter of the ceiling and what was already required.
|
|
236
|
+
*/
|
|
237
|
+
export function sourcePolicyFor(sources = [], { patterns, ceiling = 'device', base = 'any' } = {}) {
|
|
238
|
+
const list = (Array.isArray(sources) ? sources : [sources]).filter(Boolean);
|
|
239
|
+
const hits = [];
|
|
240
|
+
for (const s of list) {
|
|
241
|
+
const url = typeof s === 'string' ? s : (s?.url || s?.href || '');
|
|
242
|
+
if (!url) continue;
|
|
243
|
+
const c = classifySource(url, patterns ? { patterns } : undefined);
|
|
244
|
+
if (c.internal) hits.push({ url, host: c.host, matched: c.matched });
|
|
245
|
+
}
|
|
246
|
+
if (!hits.length) return { internal: false, reach: base, hits: [], why: null };
|
|
247
|
+
const safeCeiling = REACH_ORDER.includes(ceiling) ? ceiling : 'device';
|
|
248
|
+
return {
|
|
249
|
+
internal: true,
|
|
250
|
+
reach: meetReach(base, safeCeiling),
|
|
251
|
+
hits,
|
|
252
|
+
// Named so the person can see WHICH rule pinned the turn — an unexplained restriction
|
|
253
|
+
// gets switched off wholesale.
|
|
254
|
+
why: `${hits[0].host || 'the source'} matches '${hits[0].matched}' — kept ${safeCeiling === 'device' ? 'on this device' : 'inside your workspace'}`,
|
|
255
|
+
};
|
|
256
|
+
}
|