@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 ADDED
@@ -0,0 +1,168 @@
1
+ # PolyForm Shield License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/shield/1.0.0>
4
+
5
+ Required Notice: Copyright © 2026 ChatPanel (https://chatpanel.net)
6
+
7
+ Licensor Line of Business: ChatPanel — an AI browser side-panel, its local
8
+ bridge, and related developer tools and services (https://chatpanel.net)
9
+
10
+ ## Acceptance
11
+
12
+ In order to get any license under these terms, you must agree
13
+ to them as both strict obligations and conditions to all
14
+ your licenses.
15
+
16
+ ## Copyright License
17
+
18
+ The licensor grants you a copyright license for the
19
+ software to do everything you might do with the software
20
+ that would otherwise infringe the licensor's copyright
21
+ in it for any permitted purpose. However, you may
22
+ only distribute the software according to [Distribution
23
+ License](#distribution-license) and make changes or new works
24
+ based on the software according to [Changes and New Works
25
+ License](#changes-and-new-works-license).
26
+
27
+ ## Distribution License
28
+
29
+ The licensor grants you an additional copyright license to
30
+ distribute copies of the software. Your license to distribute
31
+ covers distributing the software with changes and new works
32
+ permitted by [Changes and New Works License](#changes-and-new-works-license).
33
+
34
+ ## Notices
35
+
36
+ You must ensure that anyone who gets a copy of any part of
37
+ the software from you also gets a copy of these terms or the
38
+ URL for them above, as well as copies of any plain-text lines
39
+ beginning with `Required Notice:` that the licensor provided
40
+ with the software. For example:
41
+
42
+ > Required Notice: Copyright © 2026 ChatPanel (https://chatpanel.net)
43
+
44
+ ## Changes and New Works License
45
+
46
+ The licensor grants you an additional copyright license to
47
+ make changes and new works based on the software for any
48
+ permitted purpose.
49
+
50
+ ## Patent License
51
+
52
+ The licensor grants you a patent license for the software that
53
+ covers patent claims the licensor can license, or becomes able
54
+ to license, that you would infringe by using the software.
55
+
56
+ ## Noncompete
57
+
58
+ Any purpose is a permitted purpose, except for providing any
59
+ product that competes with the software or any product the
60
+ licensor or any of its affiliates provides using the software.
61
+
62
+ ## Competition
63
+
64
+ Goods and services compete even when they provide functionality
65
+ through different kinds of interfaces or for different technical
66
+ platforms. Applications can compete with services, libraries
67
+ with plugins, frameworks with development tools, and so on,
68
+ even if they're written in different programming languages
69
+ or for different computer architectures. Goods and services
70
+ compete even when provided free of charge. If you market a
71
+ product as a practical substitute for the software or another
72
+ product, it definitely competes.
73
+
74
+ ## New Products
75
+
76
+ If you are using the software to provide a product that does
77
+ not compete, but the licensor or any of its affiliates brings
78
+ your product into competition by providing a new version of
79
+ the software or another product using the software, you may
80
+ continue using versions of the software available under these
81
+ terms beforehand to provide your competing product, but not
82
+ any later versions.
83
+
84
+ ## Discontinued Products
85
+
86
+ You may begin using the software to compete with a product
87
+ or service that the licensor or any of its affiliates has
88
+ stopped providing, unless the licensor includes a plain-text
89
+ line beginning with `Licensor Line of Business:` with the
90
+ software that mentions that line of business. For example:
91
+
92
+ > Licensor Line of Business: ChatPanel — an AI browser side-panel, its local
93
+ > bridge, and related developer tools and services (https://chatpanel.net)
94
+
95
+ ## Sales of Business
96
+
97
+ If the licensor or any of its affiliates sells a line of
98
+ business developing the software or using the software
99
+ to provide a product, the buyer can also enforce
100
+ Noncompete for that product.
101
+
102
+ ## Fair Use
103
+
104
+ You may have "fair use" rights for the software under the
105
+ law. These terms do not limit them.
106
+
107
+ ## No Other Rights
108
+
109
+ These terms do not allow you to sublicense or transfer any of
110
+ your licenses to anyone else, or prevent the licensor from
111
+ granting licenses to anyone else. These terms do not imply
112
+ any other licenses.
113
+
114
+ ## Patent Defense
115
+
116
+ If you make any written claim that the software infringes or
117
+ contributes to infringement of any patent, your patent license
118
+ for the software granted under these terms ends immediately. If
119
+ your company makes such a claim, your patent license ends
120
+ immediately for work on behalf of your company.
121
+
122
+ ## Violations
123
+
124
+ The first time you are notified in writing that you have
125
+ violated any of these terms, or done anything with the software
126
+ not covered by your licenses, your licenses can nonetheless
127
+ continue if you come into full compliance with these terms,
128
+ and take practical steps to correct past violations, within
129
+ 32 days of receiving notice. Otherwise, all your licenses
130
+ end immediately.
131
+
132
+ ## No Liability
133
+
134
+ ***As far as the law allows, the software comes as is, without
135
+ any warranty or condition, and the licensor will not be liable
136
+ to you for any damages arising out of these terms or the use
137
+ or nature of the software, under any kind of legal claim.***
138
+
139
+ ## Definitions
140
+
141
+ The **licensor** is the individual or entity offering these
142
+ terms, and the **software** is the software the licensor makes
143
+ available under these terms.
144
+
145
+ A **product** can be a good or service, or a combination
146
+ of them.
147
+
148
+ **You** refers to the individual or entity agreeing to these
149
+ terms.
150
+
151
+ **Your company** is any legal entity, sole proprietorship,
152
+ or other kind of organization that you work for, plus all
153
+ its affiliates.
154
+
155
+ **Affiliates** means the other organizations that an
156
+ organization has control over, is under the control of, or is
157
+ under common control with.
158
+
159
+ **Control** means ownership of substantially all the assets of
160
+ an entity, or the power to direct its management and policies
161
+ by vote, contract, or otherwise. Control can be direct or
162
+ indirect.
163
+
164
+ **Your licenses** are all the licenses granted to you for the
165
+ software under these terms.
166
+
167
+ **Use** means anything you do with the software requiring one
168
+ of your licenses.
package/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # @chatpanel/events
2
+
3
+ The canonical ChatPanel **event-log** and **capability** contracts. Pure, dependency-free
4
+ ESM — the identical code runs in the extension (browser, MV3/CSP-safe), the gateway and
5
+ the bridge, the same way [`@chatpanel/pii`](https://github.com/chatpanel/chatpanel-pii)
6
+ does.
7
+
8
+ Two contracts everything else inherits from:
9
+
10
+ - **The event schema** — append-only, versioned forever, metadata only, and ordered
11
+ **without clocks**.
12
+ - **The capability signature** — one call shape a rule, a schedule, the user or a model
13
+ all invoke identically.
14
+
15
+ ## Why it exists
16
+
17
+ A ChatPanel run should be reconstructable: what context was assembled, which capability
18
+ ran, in which class and on which runtime, what was redacted, and what left the device.
19
+ That record is only trustworthy if it is *checkable*, so this package ships the
20
+ invariants alongside the types.
21
+
22
+ ## Ordering without clocks
23
+
24
+ ```js
25
+ import { linearize } from '@chatpanel/events'
26
+ ```
27
+
28
+ > Replay orders events by topological sort over `causes`, breaking ties by `(host, seq)`
29
+ > with hosts in lexicographic id order. **Wall time is never consulted.**
30
+
31
+ Once two hosts append concurrently a timestamp is not an order — clocks skew, and two
32
+ hosts can stamp the same millisecond. `at` is advisory and shown to humans; `(host, seq)`
33
+ and `causes` are authority. `linearize()` is therefore a function of the event *set*, not
34
+ of the array it was handed.
35
+
36
+ ## Durable facts, not streams
37
+
38
+ Market ticks, live captions and DOM mutations are **not** events. They live in an
39
+ in-memory ring buffer; only the windowed aggregate that entered a model request or
40
+ crossed the device boundary is promoted. This is structural — there is no event type
41
+ that would accept a per-tick item — because durably logging a caption stream is roughly
42
+ 3 MB per meeting and 5.5 GB a year.
43
+
44
+ ## Metadata only
45
+
46
+ Events carry [`Ref`](./ref.js)s and counts, never content:
47
+
48
+ ```js
49
+ makeRef({ kind: 'note', id: 'n_88', hash: 'sha256:…', range: { from: 10, to: 40 } })
50
+ ```
51
+
52
+ Replay resolves a `Ref` by hash: match → exact reconstruction; absent or crypto-shredded
53
+ → `verified-but-unavailable`. It never silently substitutes today's version of the note.
54
+ `privacy.redacted` carries how many of each entity type were redacted and never the
55
+ values — a log of what was redacted must not itself contain the redacted data.
56
+
57
+ ## The capability signature
58
+
59
+ ```js
60
+ validateCapability({
61
+ id: 'page.actions', version: '1.0.0', class: 'R',
62
+ requires: ['tab'], provides: ['page.tools'],
63
+ reads: ['page'], writes: ['page'],
64
+ egress: 'none', effects: 'non-replayable',
65
+ disclose: () => ({ name: 'page.actions', gist: 'Act on the current web page' }),
66
+ output: { schema: …, render: (v) => … },
67
+ invoke: async (input, ctx) => …,
68
+ })
69
+ ```
70
+
71
+ `actor` on an invocation is what makes capabilities turn-independent. `requirements`
72
+ (`maxLatencyMs`, `deterministic`, `egress`, `maxCostUsd`) is what a router dispatches on —
73
+ *what must be true*, not *which model* — and `canSatisfy()` **refuses** rather than
74
+ silently exceeding a budget.
75
+
76
+ Class is intrinsic (a determinism guarantee); latency is host-bound. The two are never
77
+ fused into one table.
78
+
79
+ `toModelSchema()` is an **allowlist** built from three fields, never an omit-list — so
80
+ `invoke`, `effects`, `cost`, `writes` and `egress` cannot leak into a model request.
81
+
82
+ ## Stores — persistence is a host adapter
83
+
84
+ ```js
85
+ const log = createLogStore(adapter) // append-only events
86
+ const blobs = createBlobStore(adapter) // content-addressed, deduped
87
+ ```
88
+
89
+ The *semantics* live here; each host supplies the storage underneath — IndexedDB in the
90
+ extension, SQLite on a gateway or daemon, a capped ring buffer colocated. An in-memory
91
+ adapter ships for tests, colocated hosts and the replay harness.
92
+
93
+ `append()` is **idempotent on event id**, so replicating to a warm tier can retry
94
+ safely — the log-level counterpart of the idempotency keys capabilities carry. A seq that
95
+ moves *backwards* for a host is rejected as a corrupt writer; gaps are allowed, because
96
+ eviction must not corrupt the log. `cursor()` and `since()` are the replication pair.
97
+
98
+ The two stores are separate so **crypto-shredding** works: `blobs.shred(hash)` drops the
99
+ payload and leaves a tombstone, so "delete this meeting" can be honoured against an
100
+ append-only log while every event that referenced it, and the causality chain, stay
101
+ intact. Replay then reports `verified-but-unavailable`.
102
+
103
+ ## The registry — effects and reactive availability
104
+
105
+ ```js
106
+ const reg = createRegistry({ onEvent })
107
+ reg.register({ name: 'page-tools', requires: ['tab'], apply(ctx) {
108
+ ctx.effect(() => { const off = arm(); return () => off() }) // unwinds automatically
109
+ }})
110
+ const withdraw = reg.provide('tab', tab) // page-tools activates
111
+ withdraw() // page-tools deactivates and unwinds
112
+ ```
113
+
114
+ This is the runtime half of the capability contract: `requires`/`provides` in a
115
+ declaration only mean something because something binds them here. Two rules carry it,
116
+ and each prevents a specific bug class:
117
+
118
+ 1. **LIFO disposal** — inverses run in reverse order of registration, so each meets the
119
+ state its own application produced.
120
+ 2. **Dependents deactivate *before* a provider's binding is removed** — a component
121
+ being torn down because its provider is leaving is running teardown that frequently
122
+ *needs* the very capability being withdrawn (closing a pool means handing connections
123
+ back). Remove the binding first and that teardown reaches for something already gone.
124
+
125
+ A failing component is recorded on itself, unwinds whatever it registered, and leaves
126
+ its siblings running. A dependency cycle simply leaves its components permanently
127
+ inactive — and unlike a schedule-dependent deadlock it is visible from the declarations
128
+ alone, so `pending()` can report it at load time.
129
+
130
+ No dependency *resolution*: this binds availability, it does not solve versions.
131
+
132
+ ## Invariants
133
+
134
+ ```js
135
+ checkInvariants(events) // → [] when the log is sound
136
+ ```
137
+
138
+ | | |
139
+ |---|---|
140
+ | **I1** | Model-visible input is reconstructable from the log |
141
+ | **I2** | Every egress is recorded (`controlled: false` for delegated agents) |
142
+ | **I3** | Non-pure invocations carry an idempotency key |
143
+ | **I4** | Every activation has a recorded inverse |
144
+ | **I5** | Ephemeral streams never become durable facts |
145
+ | **I6** | Replay is deterministic |
146
+
147
+ I3 is additionally structural: a non-pure `capability.invoked` without a key fails
148
+ `validateEvent`, so it cannot enter the log at all.
149
+
150
+ ## The replay harness
151
+
152
+ ```js
153
+ const report = replay(parseJsonl(log), { blobs })
154
+ if (!report.ok) { console.error(formatReport(report)); process.exit(1) }
155
+ ```
156
+
157
+ Run it in CI and the determinism claim stops being a comment. It reproduces order from
158
+ `(host, seq)` and `causes`, checks I1–I6, and resolves every resident `Ref` by hash.
159
+
160
+ Two outcomes are worth distinguishing, because they look similar and mean opposite
161
+ things:
162
+
163
+ - a blob that is **gone** (crypto-shredded or evicted) reports *verified-but-unavailable*
164
+ and **passes** — shredding is a feature, and the log still proves what was sent;
165
+ - a source that **changed** reports *drifted* and **fails**, because the alternative is
166
+ replay quietly substituting today's note for the one actually sent.
167
+
168
+ ## Install
169
+
170
+ ```sh
171
+ npm install @chatpanel/events
172
+ ```
173
+
174
+ Node ≥ 20. No dependencies. `node --test tests/*.test.js`.
175
+
176
+ The default `digest` and id factory use the **global** WebCrypto, which every browser has
177
+ and which Node exposes without a flag from 19 onward — hence ≥ 20 rather than ≥ 18 (EOL
178
+ since April 2025). Both are injectable, so a host with its own crypto never touches the
179
+ default.
180
+
181
+ ## License
182
+
183
+ See [LICENSE](./LICENSE).
package/adapters.js ADDED
@@ -0,0 +1,83 @@
1
+ // Surface adapters — the plugin contract for "this app is driven better by its own data
2
+ // format than by pointer automation".
3
+ //
4
+ // Excalidraw is drawn by writing scene elements, a spreadsheet is filled by addressing
5
+ // cells, a diagram tool by inserting nodes. Each of those is the SAME shape of thing:
6
+ // recognise a page, offer a tool, say how to use it, execute it. That was hardcoded as a
7
+ // list inside one 1,200-line module, which meant every new app edited a shared file and
8
+ // nothing outside the extension could offer one at all.
9
+ //
10
+ // As a plugin contract instead (P15): an adapter is declared, registered with the kernel,
11
+ // and discovered. The desktop and mobile clients get the same registry; a user or a skill
12
+ // can eventually contribute one without touching this code.
13
+ //
14
+ // WHAT IS SHARED IS THE CONTRACT, NOT THE ADAPTER. Recognition and selection are pure and
15
+ // live here; the execution of any real adapter needs a platform (chrome.scripting, CDP)
16
+ // and therefore lives in the client, injected at registration.
17
+
18
+ export class AdapterError extends Error {
19
+ constructor(code, message) { super(message); this.name = 'AdapterError'; this.code = code; }
20
+ }
21
+
22
+ /**
23
+ * Declare an adapter.
24
+ *
25
+ * @param matches (url, caps) => boolean. Given the URL and whatever capability probe the
26
+ * host performed, so an adapter can recognise a self-hosted or embedded
27
+ * instance rather than only a known hostname — the reason a hostname table
28
+ * was rejected in the first place.
29
+ * @param priority higher wins when two adapters match. Ties break on registration order,
30
+ * so the answer is stable rather than dependent on activation timing.
31
+ */
32
+ export function defineAdapter({ id, label, matches, toolSpecs, guidance, execute, priority = 0 }) {
33
+ if (!id) throw new AdapterError('BAD_ADAPTER', 'adapter.id required');
34
+ if (typeof matches !== 'function') throw new AdapterError('BAD_ADAPTER', `adapter '${id}': matches required`);
35
+ if (typeof execute !== 'function') throw new AdapterError('BAD_ADAPTER', `adapter '${id}': execute required`);
36
+ return Object.freeze({
37
+ id,
38
+ label: label || id,
39
+ matches,
40
+ priority,
41
+ toolSpecs: typeof toolSpecs === 'function' ? toolSpecs : () => [],
42
+ guidance: typeof guidance === 'function' ? guidance : () => '',
43
+ execute,
44
+ });
45
+ }
46
+
47
+ /**
48
+ * The registry a host binds adapters into.
49
+ *
50
+ * Deliberately independent of the kernel: a plugin registers through the kernel and calls
51
+ * `add` from its activate, but a host with no kernel yet can still use this. Coupling them
52
+ * would make the plugin model a prerequisite for a feature rather than a way to build it.
53
+ */
54
+ export function createAdapterRegistry() {
55
+ const adapters = [];
56
+ return {
57
+ /** Register an adapter. Returns its remover, so registration is revertible (P15). */
58
+ add(adapter) {
59
+ adapters.push(adapter);
60
+ return () => {
61
+ const i = adapters.indexOf(adapter);
62
+ if (i >= 0) adapters.splice(i, 1);
63
+ };
64
+ },
65
+
66
+ list: () => [...adapters],
67
+
68
+ /**
69
+ * The adapter for a page, or null.
70
+ *
71
+ * A matcher that throws is treated as "no match" rather than taking the page down: one
72
+ * badly-written adapter must not stop the others being offered, which is the same
73
+ * isolation rule the source registry follows.
74
+ */
75
+ for(url, caps = {}) {
76
+ const hits = adapters.filter((a) => {
77
+ try { return !!a.matches(url, caps); } catch { return false; }
78
+ });
79
+ if (!hits.length) return null;
80
+ return hits.sort((a, b) => b.priority - a.priority)[0];
81
+ },
82
+ };
83
+ }
package/capability.js ADDED
@@ -0,0 +1,121 @@
1
+ // The capability signature — one call shape a rule, a schedule, the user or a model all
2
+ // invoke identically, through one policy path.
3
+ //
4
+ // `actor` is the field that makes capabilities turn-independent; it is the whole of the
5
+ // "capabilities are not turn-shaped" principle expressed as data rather than as a
6
+ // subsystem.
7
+ //
8
+ // `requirements` is what the router dispatches on: not "which model" but "what must be
9
+ // true". {maxLatencyMs:100, deterministic:true, egress:'none'} selects class R or M on a
10
+ // host that can realize it — or REFUSES. Silently exceeding a declared budget is the
11
+ // failure mode this exists to prevent.
12
+
13
+ import { CLASSES, EFFECTS, EGRESS, ACTOR_KINDS, SCOPE_KINDS, EventError } from './event.js';
14
+
15
+ export const DATA_SCOPES = Object.freeze(['notes', 'meetings', 'chats', 'page', 'files', 'net']);
16
+
17
+ const str = (v) => typeof v === 'string' && v.length > 0;
18
+ const strs = (v, allowed = null) => Array.isArray(v) && v.every((x) => str(x) && (!allowed || allowed.includes(x)));
19
+
20
+ /**
21
+ * Validate a capability DECLARATION — the static surface a reviewer, a user or an admin
22
+ * approves BEFORE the capability runs. Everything here is readable without executing
23
+ * anything, which is what makes load-time approval possible.
24
+ */
25
+ export function validateCapability(c) {
26
+ if (!c || typeof c !== 'object') throw new EventError('SHAPE', 'capability must be an object');
27
+ if (!str(c.id)) throw new EventError('SHAPE', 'capability.id required');
28
+ if (!str(c.version)) throw new EventError('SHAPE', 'capability.version required');
29
+ if (!CLASSES.includes(c.class)) throw new EventError('SHAPE', `capability.class must be one of ${CLASSES}`);
30
+ if (!strs(c.requires)) throw new EventError('SHAPE', 'capability.requires must be string[]');
31
+ if (!strs(c.provides)) throw new EventError('SHAPE', 'capability.provides must be string[]');
32
+ if (!strs(c.reads, DATA_SCOPES)) throw new EventError('SHAPE', `capability.reads must be within ${DATA_SCOPES}`);
33
+ if (!strs(c.writes, DATA_SCOPES)) throw new EventError('SHAPE', `capability.writes must be within ${DATA_SCOPES}`);
34
+ if (!EGRESS.includes(c.egress)) throw new EventError('SHAPE', `capability.egress must be one of ${EGRESS}`);
35
+ if (!EFFECTS.includes(c.effects)) throw new EventError('SHAPE', `capability.effects must be one of ${EFFECTS}`);
36
+ if (typeof c.invoke !== 'function') throw new EventError('SHAPE', 'capability.invoke required');
37
+ if (typeof c.disclose !== 'function') throw new EventError('SHAPE', 'capability.disclose required');
38
+ if (!c.output || typeof c.output.render !== 'function') {
39
+ throw new EventError('SHAPE', 'capability.output.render required — canonical value and rendering are separate');
40
+ }
41
+ // A class-R capability that declares egress is a contradiction: R is a determinism
42
+ // guarantee, and a network round-trip is not deterministic.
43
+ if (c.class === 'R' && c.egress !== 'none') {
44
+ throw new EventError('CONTRADICTION', 'class R must declare egress:none');
45
+ }
46
+ return c;
47
+ }
48
+
49
+ /**
50
+ * Validate an INVOCATION. Enforces the one rule that is easiest to forget and worst to
51
+ * miss: a capability that is not `pure` cannot be invoked without an idempotency key,
52
+ * because a retried delegated call would otherwise perform the side effect twice.
53
+ */
54
+ export function validateInvocation(inv, capability) {
55
+ if (!inv || typeof inv !== 'object') throw new EventError('SHAPE', 'invocation must be an object');
56
+ if (!str(inv.capability)) throw new EventError('SHAPE', 'invocation.capability required');
57
+ if (!inv.actor || !ACTOR_KINDS.includes(inv.actor.kind) || !str(inv.actor.id)) {
58
+ throw new EventError('SHAPE', `invocation.actor.kind must be one of ${ACTOR_KINDS}`);
59
+ }
60
+ if (!inv.scope || !SCOPE_KINDS.includes(inv.scope.kind) || !str(inv.scope.id)) {
61
+ throw new EventError('SHAPE', `invocation.scope.kind must be one of ${SCOPE_KINDS}`);
62
+ }
63
+ if (!Array.isArray(inv.causes)) throw new EventError('SHAPE', 'invocation.causes must be string[]');
64
+ const effects = capability ? capability.effects : inv.effects;
65
+ if (effects && effects !== 'pure' && !str(inv.idempotencyKey)) {
66
+ throw new EventError('IDEMPOTENCY', `invocation of a '${effects}' capability requires an idempotencyKey`);
67
+ }
68
+ return inv;
69
+ }
70
+
71
+ /**
72
+ * Can this capability satisfy these requirements on this host?
73
+ * Returns { ok, reasons[] } — REFUSING is a valid, expected outcome.
74
+ *
75
+ * `host` supplies what it can actually realize: { realizes: {R:{maxMs},M:{maxMs},...} }.
76
+ * Class is intrinsic (a guarantee); latency is host-bound. Never fuse the two.
77
+ */
78
+ export function canSatisfy(capability, requirements = {}, host = null) {
79
+ const reasons = [];
80
+ const { maxLatencyMs, deterministic, egress, maxCostUsd } = requirements;
81
+
82
+ if (deterministic === true && !['R', 'M'].includes(capability.class)) {
83
+ reasons.push(`class ${capability.class} is not deterministic`);
84
+ }
85
+ if (egress === 'none' && capability.egress !== 'none') {
86
+ reasons.push(`capability egresses '${capability.egress}', requirement is 'none'`);
87
+ }
88
+ if (egress === 'redacted' && capability.egress === 'delegated') {
89
+ reasons.push('delegated egress is not controlled, requirement is redacted');
90
+ }
91
+ if (maxLatencyMs != null && host) {
92
+ const realized = host.realizes && host.realizes[capability.class];
93
+ if (!realized) reasons.push(`host cannot realize class ${capability.class}`);
94
+ else if (realized.maxMs > maxLatencyMs) {
95
+ reasons.push(`host realizes class ${capability.class} at ~${realized.maxMs}ms, requirement is ${maxLatencyMs}ms`);
96
+ }
97
+ }
98
+ if (maxCostUsd != null && capability.class === 'C' && maxCostUsd <= 0) {
99
+ reasons.push('cloud class requires a positive cost ceiling');
100
+ }
101
+ return { ok: reasons.length === 0, reasons };
102
+ }
103
+
104
+ /**
105
+ * The model-facing projection — an ALLOWLIST built from exactly three fields.
106
+ *
107
+ * Never an omit-list. An omit-list leaks the next field someone adds; this cannot,
108
+ * because `invoke`, `effects`, `cost`, `writes` and `egress` are never copied.
109
+ */
110
+ export function toModelSchema(capability) {
111
+ return {
112
+ name: capability.id,
113
+ description: capability.disclose().gist,
114
+ parameters: capability.input || { type: 'object', properties: {} },
115
+ };
116
+ }
117
+
118
+ /** The same allowlist over a toolset — the only supported way to build a model request. */
119
+ export function toModelSchemas(capabilities) {
120
+ return capabilities.map(toModelSchema);
121
+ }
package/citations.js ADDED
@@ -0,0 +1,79 @@
1
+ // Turn the citations a model wrote into the citations a reader can click.
2
+ //
3
+ // Models are told to cite as markdown links and finish with a Sources section. Large ones
4
+ // mostly comply; small ones write bare `[1]` and stop — and a user then sees numbers that
5
+ // reference nothing, with the URLs sitting unused in a tool result they never see. That is
6
+ // exactly what happened on a real answer about SpaceX: five sources fetched, five bracket
7
+ // numbers rendered, no links anywhere.
8
+ //
9
+ // The instinct is to write a firmer instruction. But the mapping from [1] to a URL is
10
+ // already known EXACTLY — the tool result numbered them — so this is a substitution, not a
11
+ // judgement, and a deterministic pass cannot fail to follow it. Prompting is the wrong tool
12
+ // for something a rule can guarantee.
13
+ //
14
+ // Shared rather than client-side: any client that shows model output with sources needs
15
+ // this, and the numbering convention belongs with the tool contract that produced it.
16
+
17
+ /** `[1] [Title](https://url)` — the citation index every search result opens with. */
18
+ const INDEX_RE = /^\[(\d+)\]\s*\[([^\]]*)\]\(([^)\s]+)\)/gm;
19
+
20
+ /**
21
+ * Recover the numbered sources from a tool result.
22
+ *
23
+ * Parsed from the SAME text the model was shown, so the numbers can never disagree with
24
+ * what it read — deriving them from anywhere else would reintroduce the mismatch this
25
+ * exists to remove.
26
+ */
27
+ export function sourcesFromToolText(text) {
28
+ const out = new Map();
29
+ for (const m of String(text || '').matchAll(INDEX_RE)) {
30
+ const rank = Number(m[1]);
31
+ if (!out.has(rank)) out.set(rank, { rank, title: m[2].trim(), url: m[3] });
32
+ }
33
+ return [...out.values()].sort((a, b) => a.rank - b.rank);
34
+ }
35
+
36
+ // A bare citation: [1] or [1, 3] or [1,3] — but NOT a markdown link `[x](url)`, NOT a
37
+ // footnote definition at line start, and not `[]`.
38
+ const BARE_RE = /\[(\d+(?:\s*,\s*\d+)*)\](?!\()/g;
39
+
40
+ /**
41
+ * Rewrite bare `[n]` citations as markdown links, and append a Sources section listing
42
+ * exactly what was cited.
43
+ *
44
+ * Only ever ADDS links; text the model already wrote as a link is untouched, and a number
45
+ * with no matching source is left exactly as it is — inventing a link for `[7]` when seven
46
+ * sources were never returned would be fabricating a citation, which is worse than an
47
+ * unlinked number.
48
+ */
49
+ export function linkifyCitations(answer, sources, { heading = 'Sources' } = {}) {
50
+ const text = String(answer ?? '');
51
+ const byRank = new Map((sources || []).filter((s) => s?.url).map((s) => [Number(s.rank), s]));
52
+ if (!text.trim() || !byRank.size) return text;
53
+
54
+ const cited = new Set();
55
+ // Skip fenced code: a `[1]` inside a code block is code, not a citation.
56
+ const parts = text.split(/(```[\s\S]*?```|`[^`\n]*`)/g);
57
+ const linked = parts.map((part, i) => {
58
+ if (i % 2 === 1) return part; // the captured code spans
59
+ return part.replace(BARE_RE, (whole, group) => {
60
+ const ranks = group.split(',').map((n) => Number(n.trim()));
61
+ if (!ranks.every((n) => byRank.has(n))) return whole; // unknown number → leave alone
62
+ ranks.forEach((n) => cited.add(n));
63
+ return ranks.map((n) => `([${n}](${byRank.get(n).url}))`).join(' ');
64
+ });
65
+ }).join('');
66
+
67
+ if (!cited.size) return linked;
68
+
69
+ // A Sources section the model already wrote is left alone — appending a second one is a
70
+ // worse outcome than a slightly differently-formatted first.
71
+ // Match the heading however it was written — `## Sources`, `**Sources**`, or bare. The
72
+ // first version only matched the bare form, so a model that bolded it got a second one.
73
+ if (new RegExp(`(^|\\n)\\s*(?:#{1,6}\\s*|\\*\\*)?${heading}\\b`, 'i').test(linked)) return linked;
74
+
75
+ const list = [...cited].sort((a, b) => a - b)
76
+ .map((n) => { const s = byRank.get(n); return `${n}. [${s.title || s.url}](${s.url})`; })
77
+ .join('\n');
78
+ return `${linked.trimEnd()}\n\n**${heading}**\n${list}\n`;
79
+ }