@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/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
|
+
}
|