@chatpanel/events 0.102.0 → 0.104.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/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +4 -5
- package/pdf-layout.js +7 -253
- package/policy.js +1 -0
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -442
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/capability.js
CHANGED
|
@@ -1,126 +1 @@
|
|
|
1
|
-
|
|
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
|
-
import { DATA_SCOPES } from './scopes.js';
|
|
15
|
-
import { validateView } from './view.js';
|
|
16
|
-
|
|
17
|
-
export { DATA_SCOPES } from './scopes.js';
|
|
18
|
-
|
|
19
|
-
const str = (v) => typeof v === 'string' && v.length > 0;
|
|
20
|
-
const strs = (v, allowed = null) => Array.isArray(v) && v.every((x) => str(x) && (!allowed || allowed.includes(x)));
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Validate a capability DECLARATION — the static surface a reviewer, a user or an admin
|
|
24
|
-
* approves BEFORE the capability runs. Everything here is readable without executing
|
|
25
|
-
* anything, which is what makes load-time approval possible.
|
|
26
|
-
*/
|
|
27
|
-
export function validateCapability(c) {
|
|
28
|
-
if (!c || typeof c !== 'object') throw new EventError('SHAPE', 'capability must be an object');
|
|
29
|
-
if (!str(c.id)) throw new EventError('SHAPE', 'capability.id required');
|
|
30
|
-
if (!str(c.version)) throw new EventError('SHAPE', 'capability.version required');
|
|
31
|
-
if (!CLASSES.includes(c.class)) throw new EventError('SHAPE', `capability.class must be one of ${CLASSES}`);
|
|
32
|
-
if (!strs(c.requires)) throw new EventError('SHAPE', 'capability.requires must be string[]');
|
|
33
|
-
if (!strs(c.provides)) throw new EventError('SHAPE', 'capability.provides must be string[]');
|
|
34
|
-
if (!strs(c.reads, DATA_SCOPES)) throw new EventError('SHAPE', `capability.reads must be within ${DATA_SCOPES}`);
|
|
35
|
-
if (!strs(c.writes, DATA_SCOPES)) throw new EventError('SHAPE', `capability.writes must be within ${DATA_SCOPES}`);
|
|
36
|
-
if (!EGRESS.includes(c.egress)) throw new EventError('SHAPE', `capability.egress must be one of ${EGRESS}`);
|
|
37
|
-
if (!EFFECTS.includes(c.effects)) throw new EventError('SHAPE', `capability.effects must be one of ${EFFECTS}`);
|
|
38
|
-
if (typeof c.invoke !== 'function') throw new EventError('SHAPE', 'capability.invoke required');
|
|
39
|
-
if (typeof c.disclose !== 'function') throw new EventError('SHAPE', 'capability.disclose required');
|
|
40
|
-
if (!c.output || typeof c.output.render !== 'function') {
|
|
41
|
-
throw new EventError('SHAPE', 'capability.output.render required — canonical value and rendering are separate');
|
|
42
|
-
}
|
|
43
|
-
// A capability MAY ship its own UI. Optional, and validated against this capability so a
|
|
44
|
-
// view can never name a capability its owner isn't already allowed to call.
|
|
45
|
-
if (c.view != null) validateView(c.view, c);
|
|
46
|
-
// A class-R capability that declares egress is a contradiction: R is a determinism
|
|
47
|
-
// guarantee, and a network round-trip is not deterministic.
|
|
48
|
-
if (c.class === 'R' && c.egress !== 'none') {
|
|
49
|
-
throw new EventError('CONTRADICTION', 'class R must declare egress:none');
|
|
50
|
-
}
|
|
51
|
-
return c;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Validate an INVOCATION. Enforces the one rule that is easiest to forget and worst to
|
|
56
|
-
* miss: a capability that is not `pure` cannot be invoked without an idempotency key,
|
|
57
|
-
* because a retried delegated call would otherwise perform the side effect twice.
|
|
58
|
-
*/
|
|
59
|
-
export function validateInvocation(inv, capability) {
|
|
60
|
-
if (!inv || typeof inv !== 'object') throw new EventError('SHAPE', 'invocation must be an object');
|
|
61
|
-
if (!str(inv.capability)) throw new EventError('SHAPE', 'invocation.capability required');
|
|
62
|
-
if (!inv.actor || !ACTOR_KINDS.includes(inv.actor.kind) || !str(inv.actor.id)) {
|
|
63
|
-
throw new EventError('SHAPE', `invocation.actor.kind must be one of ${ACTOR_KINDS}`);
|
|
64
|
-
}
|
|
65
|
-
if (!inv.scope || !SCOPE_KINDS.includes(inv.scope.kind) || !str(inv.scope.id)) {
|
|
66
|
-
throw new EventError('SHAPE', `invocation.scope.kind must be one of ${SCOPE_KINDS}`);
|
|
67
|
-
}
|
|
68
|
-
if (!Array.isArray(inv.causes)) throw new EventError('SHAPE', 'invocation.causes must be string[]');
|
|
69
|
-
const effects = capability ? capability.effects : inv.effects;
|
|
70
|
-
if (effects && effects !== 'pure' && !str(inv.idempotencyKey)) {
|
|
71
|
-
throw new EventError('IDEMPOTENCY', `invocation of a '${effects}' capability requires an idempotencyKey`);
|
|
72
|
-
}
|
|
73
|
-
return inv;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Can this capability satisfy these requirements on this host?
|
|
78
|
-
* Returns { ok, reasons[] } — REFUSING is a valid, expected outcome.
|
|
79
|
-
*
|
|
80
|
-
* `host` supplies what it can actually realize: { realizes: {R:{maxMs},M:{maxMs},...} }.
|
|
81
|
-
* Class is intrinsic (a guarantee); latency is host-bound. Never fuse the two.
|
|
82
|
-
*/
|
|
83
|
-
export function canSatisfy(capability, requirements = {}, host = null) {
|
|
84
|
-
const reasons = [];
|
|
85
|
-
const { maxLatencyMs, deterministic, egress, maxCostUsd } = requirements;
|
|
86
|
-
|
|
87
|
-
if (deterministic === true && !['R', 'M'].includes(capability.class)) {
|
|
88
|
-
reasons.push(`class ${capability.class} is not deterministic`);
|
|
89
|
-
}
|
|
90
|
-
if (egress === 'none' && capability.egress !== 'none') {
|
|
91
|
-
reasons.push(`capability egresses '${capability.egress}', requirement is 'none'`);
|
|
92
|
-
}
|
|
93
|
-
if (egress === 'redacted' && capability.egress === 'delegated') {
|
|
94
|
-
reasons.push('delegated egress is not controlled, requirement is redacted');
|
|
95
|
-
}
|
|
96
|
-
if (maxLatencyMs != null && host) {
|
|
97
|
-
const realized = host.realizes && host.realizes[capability.class];
|
|
98
|
-
if (!realized) reasons.push(`host cannot realize class ${capability.class}`);
|
|
99
|
-
else if (realized.maxMs > maxLatencyMs) {
|
|
100
|
-
reasons.push(`host realizes class ${capability.class} at ~${realized.maxMs}ms, requirement is ${maxLatencyMs}ms`);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
if (maxCostUsd != null && capability.class === 'C' && maxCostUsd <= 0) {
|
|
104
|
-
reasons.push('cloud class requires a positive cost ceiling');
|
|
105
|
-
}
|
|
106
|
-
return { ok: reasons.length === 0, reasons };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* The model-facing projection — an ALLOWLIST built from exactly three fields.
|
|
111
|
-
*
|
|
112
|
-
* Never an omit-list. An omit-list leaks the next field someone adds; this cannot,
|
|
113
|
-
* because `invoke`, `effects`, `cost`, `writes` and `egress` are never copied.
|
|
114
|
-
*/
|
|
115
|
-
export function toModelSchema(capability) {
|
|
116
|
-
return {
|
|
117
|
-
name: capability.id,
|
|
118
|
-
description: capability.disclose().gist,
|
|
119
|
-
parameters: capability.input || { type: 'object', properties: {} },
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** The same allowlist over a toolset — the only supported way to build a model request. */
|
|
124
|
-
export function toModelSchemas(capabilities) {
|
|
125
|
-
return capabilities.map(toModelSchema);
|
|
126
|
-
}
|
|
1
|
+
import{CLASSES as d,EFFECTS as p,EGRESS as w,ACTOR_KINDS as m,SCOPE_KINDS as S,EventError as s}from"./event.js";import{DATA_SCOPES as n}from"./scopes.js";import{validateView as E}from"./view.js";import{DATA_SCOPES as v}from"./scopes.js";const o=e=>typeof e=="string"&&e.length>0,a=(e,r=null)=>Array.isArray(e)&&e.every(t=>o(t)&&(!r||r.includes(t)));function g(e){if(!e||typeof e!="object")throw new s("SHAPE","capability must be an object");if(!o(e.id))throw new s("SHAPE","capability.id required");if(!o(e.version))throw new s("SHAPE","capability.version required");if(!d.includes(e.class))throw new s("SHAPE",`capability.class must be one of ${d}`);if(!a(e.requires))throw new s("SHAPE","capability.requires must be string[]");if(!a(e.provides))throw new s("SHAPE","capability.provides must be string[]");if(!a(e.reads,n))throw new s("SHAPE",`capability.reads must be within ${n}`);if(!a(e.writes,n))throw new s("SHAPE",`capability.writes must be within ${n}`);if(!w.includes(e.egress))throw new s("SHAPE",`capability.egress must be one of ${w}`);if(!p.includes(e.effects))throw new s("SHAPE",`capability.effects must be one of ${p}`);if(typeof e.invoke!="function")throw new s("SHAPE","capability.invoke required");if(typeof e.disclose!="function")throw new s("SHAPE","capability.disclose required");if(!e.output||typeof e.output.render!="function")throw new s("SHAPE","capability.output.render required — canonical value and rendering are separate");if(e.view!=null&&E(e.view,e),e.class==="R"&&e.egress!=="none")throw new s("CONTRADICTION","class R must declare egress:none");return e}function H(e,r){if(!e||typeof e!="object")throw new s("SHAPE","invocation must be an object");if(!o(e.capability))throw new s("SHAPE","invocation.capability required");if(!e.actor||!m.includes(e.actor.kind)||!o(e.actor.id))throw new s("SHAPE",`invocation.actor.kind must be one of ${m}`);if(!e.scope||!S.includes(e.scope.kind)||!o(e.scope.id))throw new s("SHAPE",`invocation.scope.kind must be one of ${S}`);if(!Array.isArray(e.causes))throw new s("SHAPE","invocation.causes must be string[]");const t=r?r.effects:e.effects;if(t&&t!=="pure"&&!o(e.idempotencyKey))throw new s("IDEMPOTENCY",`invocation of a '${t}' capability requires an idempotencyKey`);return e}function $(e,r={},t=null){const i=[],{maxLatencyMs:c,deterministic:h,egress:f,maxCostUsd:l}=r;if(h===!0&&!["R","M"].includes(e.class)&&i.push(`class ${e.class} is not deterministic`),f==="none"&&e.egress!=="none"&&i.push(`capability egresses '${e.egress}', requirement is 'none'`),f==="redacted"&&e.egress==="delegated"&&i.push("delegated egress is not controlled, requirement is redacted"),c!=null&&t){const u=t.realizes&&t.realizes[e.class];u?u.maxMs>c&&i.push(`host realizes class ${e.class} at ~${u.maxMs}ms, requirement is ${c}ms`):i.push(`host cannot realize class ${e.class}`)}return l!=null&&e.class==="C"&&l<=0&&i.push("cloud class requires a positive cost ceiling"),{ok:i.length===0,reasons:i}}function A(e){return{name:e.id,description:e.disclose().gist,parameters:e.input||{type:"object",properties:{}}}}function q(e){return e.map(A)}export{v as DATA_SCOPES,$ as canSatisfy,A as toModelSchema,q as toModelSchemas,g as validateCapability,H as validateInvocation};
|
package/citations.js
CHANGED
|
@@ -1,128 +1,6 @@
|
|
|
1
|
-
|
|
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.
|
|
1
|
+
const g=/^\[(\d+)\]\s*\[([^\]]*)\]\(([^)\s]+)\)/gm;function k(n){const o=new Map;for(const e of String(n||"").matchAll(g)){const s=Number(e[1]);o.has(s)||o.set(s,{rank:s,title:e[2].trim(),url:e[3]})}return[...o.values()].sort((e,s)=>e.rank-s.rank)}const y=/\[(\d+(?:\s*,\s*\d+)*)\](?!\()/g;function d(n,o,{heading:e="Sources"}={}){const s=String(n??""),p=new Map((o||[]).filter(t=>t?.url).map(t=>[Number(t.rank),t]));if(!s.trim()||!p.size)return s;const r=new Set,c=s.split(/(```[\s\S]*?```|`[^`\n]*`)/g).map((t,a)=>a%2===1?t:t.replace(y,(h,m)=>{const f=m.split(",").map(u=>Number(u.trim()));return f.every(u=>p.has(u))?(f.forEach(u=>r.add(u)),f.map(u=>`([${u}](${p.get(u).url}))`).join(" ")):h})).join("");if(!r.size||new RegExp(`(^|\\n)\\s*(?:#{1,6}\\s*|\\*\\*)?${e}\\b`,"i").test(c))return c;const i=[...r].sort((t,a)=>t-a).map(t=>{const a=p.get(t);return`${t}. [${a.title||a.url}](${a.url})`}).join(`
|
|
2
|
+
`);return`${c.trimEnd()}
|
|
16
3
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
}
|
|
80
|
-
|
|
81
|
-
// ---------------------------------------------------------------------------------------
|
|
82
|
-
// The collector — what a turn was GIVEN, gathered as its tools return it, and put back
|
|
83
|
-
// under the answer as links. Was the extension's `citationCollector`; the desktop had none,
|
|
84
|
-
// so its answers cited `[1]` with nothing to click.
|
|
85
|
-
// ---------------------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
/** Tools whose job is to RETURN material rather than to act on something. */
|
|
88
|
-
export const RETRIEVAL_TOOLS = Object.freeze(new Set(['find', 'source', 'history_search', 'web_search', 'search', 'fetch', 'read_page']));
|
|
89
|
-
|
|
90
|
-
const retrievalName = (name) => String(name || '').replace(/^mcp[_-]/, '').split('__')[0];
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Wrap a toolset so every result is read for numbered sources.
|
|
94
|
-
*
|
|
95
|
-
* @param tools `{ execute, … }`; returned as `.tools`, wrapped
|
|
96
|
-
* @param onRetrieved `({ tool, count, chars, sources }) => void` — RETRIEVAL IS INPUT. Called
|
|
97
|
-
* per call that returned material, so the turn shows what it was given and
|
|
98
|
-
* by which tool. A retrieval tool that returned no LINKS still returned
|
|
99
|
-
* material (notes, past chats), and is reported with `count: 0` — silence
|
|
100
|
-
* there would under-report exactly the private sources this makes visible.
|
|
101
|
-
* @returns `{ tools, list(), apply(answer) }`
|
|
102
|
-
*/
|
|
103
|
-
export function createCitationCollector(tools, { onRetrieved = null } = {}) {
|
|
104
|
-
const sources = new Map();
|
|
105
|
-
const collect = (name, out) => {
|
|
106
|
-
const body = typeof out === 'string' ? out : (out?.text || '');
|
|
107
|
-
if (!body) return;
|
|
108
|
-
const found = sourcesFromToolText(body);
|
|
109
|
-
for (const s of found) if (!sources.has(s.rank)) sources.set(s.rank, s);
|
|
110
|
-
if (!onRetrieved) return;
|
|
111
|
-
try {
|
|
112
|
-
if (found.length) onRetrieved({ tool: name, count: found.length, chars: body.length, sources: found.slice(0, 20).map((x) => ({ rank: x.rank, title: x.title || '', url: x.url || '' })) });
|
|
113
|
-
else if (RETRIEVAL_TOOLS.has(retrievalName(name))) onRetrieved({ tool: name, count: 0, chars: body.length, sources: [] });
|
|
114
|
-
} catch { /* reporting never breaks a turn */ }
|
|
115
|
-
};
|
|
116
|
-
const wrapped = tools && typeof tools.execute === 'function'
|
|
117
|
-
? { ...tools, execute: async (name, input, meta) => { const out = await tools.execute(name, input, meta); collect(name, out); return out; } }
|
|
118
|
-
: tools;
|
|
119
|
-
return {
|
|
120
|
-
tools: wrapped,
|
|
121
|
-
list: () => [...sources.values()],
|
|
122
|
-
/** The answer with its `[n]` citations linked and a Sources section — or the answer untouched when nothing was retrieved. */
|
|
123
|
-
apply(answer) {
|
|
124
|
-
if (!sources.size || !answer || typeof answer !== 'string') return answer;
|
|
125
|
-
try { return linkifyCitations(answer, [...sources.values()]); } catch { return answer; }
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
|
-
}
|
|
4
|
+
**${e}**
|
|
5
|
+
${i}
|
|
6
|
+
`}const x=Object.freeze(new Set(["find","source","history_search","web_search","search","fetch","read_page"])),E=n=>String(n||"").replace(/^mcp[_-]/,"").split("__")[0];function _(n,{onRetrieved:o=null}={}){const e=new Map,s=(r,l)=>{const c=typeof l=="string"?l:l?.text||"";if(!c)return;const i=k(c);for(const t of i)e.has(t.rank)||e.set(t.rank,t);if(o)try{i.length?o({tool:r,count:i.length,chars:c.length,sources:i.slice(0,20).map(t=>({rank:t.rank,title:t.title||"",url:t.url||""}))}):x.has(E(r))&&o({tool:r,count:0,chars:c.length,sources:[]})}catch{}};return{tools:n&&typeof n.execute=="function"?{...n,execute:async(r,l,c)=>{const i=await n.execute(r,l,c);return s(r,i),i}}:n,list:()=>[...e.values()],apply(r){if(!e.size||!r||typeof r!="string")return r;try{return d(r,[...e.values()])}catch{return r}}}}export{x as RETRIEVAL_TOOLS,_ as createCitationCollector,d as linkifyCitations,k as sourcesFromToolText};
|
package/client-prefs.js
CHANGED
|
@@ -1,148 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// and the desktop, how they are cut out of the extension's settings tree and put back, and
|
|
3
|
-
// how two copies that were both edited are reconciled.
|
|
4
|
-
//
|
|
5
|
-
// The extension keeps its settings in chrome.storage, which nothing on the machine can read.
|
|
6
|
-
// The desktop and the extension DO share one address: the gateway. So the shareable settings
|
|
7
|
-
// live there as a document of SECTIONS, each stamped with when it was last written, and both
|
|
8
|
-
// clients push the sections they change and take the sections the other wrote more recently.
|
|
9
|
-
// Per-section last-writer-wins, not per-key: a section is one thing a person edits on one
|
|
10
|
-
// screen (the MCP server list, the search engines), and merging two edits of one list by key
|
|
11
|
-
// would invent a list neither of them made.
|
|
12
|
-
//
|
|
13
|
-
// WHAT DOES NOT TRAVEL, ON PURPOSE. Endpoints and their keys (the gateway's destinations are
|
|
14
|
-
// the desktop's copy of that idea, and a key should not be copied by a sync), tokens, the
|
|
15
|
-
// active agent (per surface), theme and layout (per window), anything about a browser tab.
|
|
16
|
-
//
|
|
17
|
-
// Pure: `now` is injected, and nothing here knows a storage API or a network.
|
|
18
|
-
|
|
19
|
-
/** The sections, and where each lives in the extension's settings tree. */
|
|
20
|
-
export const PREF_SECTIONS = Object.freeze([
|
|
21
|
-
{ id: 'mcpServers', label: 'MCP tool servers', path: ['mcpServers'], kind: 'array' },
|
|
22
|
-
{ id: 'skills', label: 'Skills', path: ['skills'], kind: 'array' },
|
|
23
|
-
{ id: 'skillDirs', label: 'Skill folders', path: ['ui', 'skillDirs'], kind: 'array' },
|
|
24
|
-
{ id: 'recipes', label: 'Recipes', path: ['recipes'], kind: 'array' },
|
|
25
|
-
{ id: 'teams', label: 'Teams', path: ['teams'], kind: 'array' },
|
|
26
|
-
// The agent pool (agent.js). NOT `settings.agents` — that key is the extension's harness
|
|
27
|
-
// list (its bridge "agents"), which the naming rule keeps; the pool lives beside it.
|
|
28
|
-
{ id: 'agents', label: 'Agents', path: ['agentPool'], kind: 'array' },
|
|
29
|
-
// SCM connections (scm-connection.js): kind, host, secret REF — never the token, which
|
|
30
|
-
// stays in the machine's keychain / the bridge's secret store and does not travel.
|
|
31
|
-
{ id: 'connections', label: 'Connections', path: ['connections'], kind: 'array' },
|
|
32
|
-
// The project pages (F8 §12): the goal, its stakeholder, budget, repos, gate. Jobs and runs
|
|
33
|
-
// fold on the gateway's project record, not here.
|
|
34
|
-
{ id: 'projects', label: 'Projects', path: ['projects'], kind: 'array' },
|
|
35
|
-
{ id: 'webSearch', label: 'Web search', path: ['ui', 'webSearch'], kind: 'object' },
|
|
36
|
-
{ id: 'tools', label: 'Tools', path: null, kind: 'object', keys: ['mcpToolsMode', 'maxToolsPerTurn', 'maxToolRoundsPerTurn', 'historyTools', 'historyContextMode', 'dataDispatch', 'toolResultMaxChars'] },
|
|
37
|
-
{ id: 'suggestions', label: 'Smart suggestions', path: ['ui', 'suggestions'], kind: 'object' },
|
|
38
|
-
{ id: 'topics', label: 'Topic extraction', path: ['ui', 'topicExtraction'], kind: 'object' },
|
|
39
|
-
{ id: 'voice', label: 'Voice', path: ['ui', 'voice'], kind: 'object' },
|
|
40
|
-
{ id: 'watch', label: 'Watch', path: ['ui', 'watch'], kind: 'object' },
|
|
41
|
-
{ id: 'meetings', label: 'Meetings', path: null, kind: 'object', keys: ['meetingWindowMin', 'liveNotesIntervalMin', 'alertSound'] },
|
|
42
|
-
{ id: 'redaction', label: 'Redaction (client-side)', path: ['ui', 'piiRedaction'], kind: 'object' },
|
|
43
|
-
// Internal sites (source-gate.js): which hosts are internal and how far their content may
|
|
44
|
-
// travel. A rule the desktop enforces too, or a page internal in the panel is sent
|
|
45
|
-
// anywhere from the desk.
|
|
46
|
-
{ id: 'internalSites', label: 'Internal sites', path: ['privacy'], kind: 'object' },
|
|
47
|
-
]);
|
|
48
|
-
|
|
49
|
-
export const PREF_SECTION_IDS = Object.freeze(PREF_SECTIONS.map((s) => s.id));
|
|
50
|
-
|
|
51
|
-
const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
52
|
-
|
|
53
|
-
function getPath(obj, path) {
|
|
54
|
-
let cur = obj;
|
|
55
|
-
for (const k of path) { if (!cur || typeof cur !== 'object') return undefined; cur = cur[k]; }
|
|
56
|
-
return cur;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function setPath(obj, path, value) {
|
|
60
|
-
let cur = obj;
|
|
61
|
-
for (let i = 0; i < path.length - 1; i += 1) {
|
|
62
|
-
const k = path[i];
|
|
63
|
-
if (!cur[k] || typeof cur[k] !== 'object') cur[k] = {};
|
|
64
|
-
cur = cur[k];
|
|
65
|
-
}
|
|
66
|
-
cur[path[path.length - 1]] = value;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** One section's value, read out of the extension's settings tree. `undefined` when absent. */
|
|
70
|
-
export function sectionValue(settings, id) {
|
|
71
|
-
const spec = PREF_SECTIONS.find((s) => s.id === id);
|
|
72
|
-
if (!spec || !settings) return undefined;
|
|
73
|
-
if (spec.path) return clone(getPath(settings, spec.path));
|
|
74
|
-
const ui = settings.ui || {};
|
|
75
|
-
const out = {};
|
|
76
|
-
let any = false;
|
|
77
|
-
for (const k of spec.keys) if (ui[k] !== undefined) { out[k] = clone(ui[k]); any = true; }
|
|
78
|
-
return any ? out : undefined;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Every section present in the settings tree: `{ id: value }`. */
|
|
82
|
-
export function pickSections(settings) {
|
|
83
|
-
const out = {};
|
|
84
|
-
for (const s of PREF_SECTIONS) {
|
|
85
|
-
const v = sectionValue(settings, s.id);
|
|
86
|
-
if (v !== undefined) out[s.id] = v;
|
|
87
|
-
}
|
|
88
|
-
return out;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The settings tree with these section values put back. Returns a new object. */
|
|
92
|
-
export function applySections(settings, sections) {
|
|
93
|
-
const next = clone(settings || {}) || {};
|
|
94
|
-
for (const [id, value] of Object.entries(sections || {})) {
|
|
95
|
-
const spec = PREF_SECTIONS.find((s) => s.id === id);
|
|
96
|
-
if (!spec || value === undefined) continue;
|
|
97
|
-
if (spec.path) { setPath(next, spec.path, clone(value)); continue; }
|
|
98
|
-
if (!next.ui || typeof next.ui !== 'object') next.ui = {};
|
|
99
|
-
for (const k of spec.keys) if (value && value[k] !== undefined) next.ui[k] = clone(value[k]);
|
|
100
|
-
}
|
|
101
|
-
return next;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** A stable fingerprint of a value — key order does not count as a change. */
|
|
105
|
-
export function sectionHash(value) {
|
|
106
|
-
const sort = (v) => {
|
|
107
|
-
if (Array.isArray(v)) return v.map(sort);
|
|
108
|
-
if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sort(v[k])]));
|
|
109
|
-
return v;
|
|
110
|
-
};
|
|
111
|
-
try { return JSON.stringify(sort(value)); } catch { return String(value); }
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Reconcile two stamped copies: `{ id: { value, updatedAt } }` each.
|
|
116
|
-
*
|
|
117
|
-
* The newer stamp wins per section; equal stamps keep `local` (nothing to do). Returns the
|
|
118
|
-
* merged document plus which sections each side contributed, so a client knows what to
|
|
119
|
-
* write locally (`fromRemote`) and what to push (`fromLocal`).
|
|
120
|
-
*/
|
|
121
|
-
export function mergeStamped(local = {}, remote = {}) {
|
|
122
|
-
const merged = {};
|
|
123
|
-
const fromRemote = [];
|
|
124
|
-
const fromLocal = [];
|
|
125
|
-
const ids = new Set([...Object.keys(local || {}), ...Object.keys(remote || {})]);
|
|
126
|
-
for (const id of ids) {
|
|
127
|
-
const l = local?.[id]; const r = remote?.[id];
|
|
128
|
-
const lt = Number(l?.updatedAt) || 0; const rt = Number(r?.updatedAt) || 0;
|
|
129
|
-
if (r && (!l || rt > lt)) { merged[id] = { value: clone(r.value), updatedAt: rt }; if (l ? sectionHash(l.value) !== sectionHash(r.value) : true) fromRemote.push(id); continue; }
|
|
130
|
-
if (l) { merged[id] = { value: clone(l.value), updatedAt: lt }; if (!r || lt > rt) fromLocal.push(id); }
|
|
131
|
-
}
|
|
132
|
-
return { merged, fromRemote, fromLocal };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* What a client should PUSH: sections whose value differs from what it last pushed. Each is
|
|
137
|
-
* stamped `now`, so the other side sees them as newer than its own copy.
|
|
138
|
-
*/
|
|
139
|
-
export function changedSections(sections, lastPushed = {}, { now = Date.now() } = {}) {
|
|
140
|
-
const out = {};
|
|
141
|
-
for (const [id, value] of Object.entries(sections || {})) {
|
|
142
|
-
if (!PREF_SECTION_IDS.includes(id)) continue;
|
|
143
|
-
const prev = lastPushed?.[id];
|
|
144
|
-
if (prev && prev.hash === sectionHash(value)) continue;
|
|
145
|
-
out[id] = { value: clone(value), updatedAt: now };
|
|
146
|
-
}
|
|
147
|
-
return out;
|
|
148
|
-
}
|
|
1
|
+
const l=Object.freeze([{id:"mcpServers",label:"MCP tool servers",path:["mcpServers"],kind:"array"},{id:"skills",label:"Skills",path:["skills"],kind:"array"},{id:"skillDirs",label:"Skill folders",path:["ui","skillDirs"],kind:"array"},{id:"recipes",label:"Recipes",path:["recipes"],kind:"array"},{id:"teams",label:"Teams",path:["teams"],kind:"array"},{id:"agents",label:"Agents",path:["agentPool"],kind:"array"},{id:"connections",label:"Connections",path:["connections"],kind:"array"},{id:"projects",label:"Projects",path:["projects"],kind:"array"},{id:"webSearch",label:"Web search",path:["ui","webSearch"],kind:"object"},{id:"tools",label:"Tools",path:null,kind:"object",keys:["mcpToolsMode","maxToolsPerTurn","maxToolRoundsPerTurn","historyTools","historyContextMode","dataDispatch","toolResultMaxChars"]},{id:"suggestions",label:"Smart suggestions",path:["ui","suggestions"],kind:"object"},{id:"topics",label:"Topic extraction",path:["ui","topicExtraction"],kind:"object"},{id:"voice",label:"Voice",path:["ui","voice"],kind:"object"},{id:"watch",label:"Watch",path:["ui","watch"],kind:"object"},{id:"meetings",label:"Meetings",path:null,kind:"object",keys:["meetingWindowMin","liveNotesIntervalMin","alertSound"]},{id:"redaction",label:"Redaction (client-side)",path:["ui","piiRedaction"],kind:"object"},{id:"internalSites",label:"Internal sites",path:["privacy"],kind:"object"}]),b=Object.freeze(l.map(n=>n.id)),c=n=>n===void 0?void 0:JSON.parse(JSON.stringify(n));function h(n,i){let e=n;for(const t of i){if(!e||typeof e!="object")return;e=e[t]}return e}function k(n,i,e){let t=n;for(let r=0;r<i.length-1;r+=1){const s=i[r];(!t[s]||typeof t[s]!="object")&&(t[s]={}),t=t[s]}t[i[i.length-1]]=e}function y(n,i){const e=l.find(o=>o.id===i);if(!e||!n)return;if(e.path)return c(h(n,e.path));const t=n.ui||{},r={};let s=!1;for(const o of e.keys)t[o]!==void 0&&(r[o]=c(t[o]),s=!0);return s?r:void 0}function j(n){const i={};for(const e of l){const t=y(n,e.id);t!==void 0&&(i[e.id]=t)}return i}function m(n,i){const e=c(n||{})||{};for(const[t,r]of Object.entries(i||{})){const s=l.find(o=>o.id===t);if(!(!s||r===void 0)){if(s.path){k(e,s.path,c(r));continue}(!e.ui||typeof e.ui!="object")&&(e.ui={});for(const o of s.keys)r&&r[o]!==void 0&&(e.ui[o]=c(r[o]))}}return e}function p(n){const i=e=>Array.isArray(e)?e.map(i):e&&typeof e=="object"?Object.fromEntries(Object.keys(e).sort().map(t=>[t,i(e[t])])):e;try{return JSON.stringify(i(n))}catch{return String(n)}}function S(n={},i={}){const e={},t=[],r=[],s=new Set([...Object.keys(n||{}),...Object.keys(i||{})]);for(const o of s){const a=n?.[o],d=i?.[o],u=Number(a?.updatedAt)||0,f=Number(d?.updatedAt)||0;if(d&&(!a||f>u)){e[o]={value:c(d.value),updatedAt:f},(!a||p(a.value)!==p(d.value))&&t.push(o);continue}a&&(e[o]={value:c(a.value),updatedAt:u},(!d||u>f)&&r.push(o))}return{merged:e,fromRemote:t,fromLocal:r}}function g(n,i={},{now:e=Date.now()}={}){const t={};for(const[r,s]of Object.entries(n||{})){if(!b.includes(r))continue;const o=i?.[r];o&&o.hash===p(s)||(t[r]={value:c(s),updatedAt:e})}return t}export{l as PREF_SECTIONS,b as PREF_SECTION_IDS,m as applySections,g as changedSections,S as mergeStamped,j as pickSections,p as sectionHash,y as sectionValue};
|
package/context-attachments.js
CHANGED
|
@@ -1,148 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// as <context> blocks, images as image blocks in the provider's wire shape, and — when the
|
|
6
|
-
// turn has tools and the sources are big — a MANIFEST plus a `source` tool instead of the
|
|
7
|
-
// full text, so "hi" on a long page does not pay for the page.
|
|
8
|
-
//
|
|
9
|
-
// Moved out of the extension's providers.js so the desktop's turn assembles attachments
|
|
10
|
-
// exactly the way the panel does. Pure: nothing here fetches or renders.
|
|
1
|
+
import{makeSourceStore as d,manifestText as l,readSource as f,approxTokens as p}from"./sources-retrieval.js";function h(n){let t=n.content||"";const i=(n.attachments||[]).filter(e=>e.kind!=="image");if(i.length){const e=i.map(r=>{const c=`[${r.kind||"context"}] ${r.title||r.url||""}`.trim();return`<context source="${(r.url||r.title||"").replace(/"/g,"")}">
|
|
2
|
+
# ${c}
|
|
3
|
+
${r.text||""}
|
|
4
|
+
</context>`}).join(`
|
|
11
5
|
|
|
12
|
-
|
|
6
|
+
`);t=t?`${t}
|
|
13
7
|
|
|
14
|
-
|
|
15
|
-
// Image attachments are excluded here — they go to the model as image blocks
|
|
16
|
-
// (see toMultimodalMessages), not as text.
|
|
17
|
-
export function renderContent(m) {
|
|
18
|
-
let text = m.content || '';
|
|
19
|
-
const ctx = (m.attachments || []).filter((a) => a.kind !== 'image');
|
|
20
|
-
if (ctx.length) {
|
|
21
|
-
const blocks = ctx
|
|
22
|
-
.map((a) => {
|
|
23
|
-
const head = `[${a.kind || 'context'}] ${a.title || a.url || ''}`.trim();
|
|
24
|
-
return `<context source="${(a.url || a.title || '').replace(/"/g, '')}">\n# ${head}\n${a.text || ''}\n</context>`;
|
|
25
|
-
})
|
|
26
|
-
.join('\n\n');
|
|
27
|
-
text = text ? `${text}\n\n${blocks}` : blocks;
|
|
28
|
-
}
|
|
29
|
-
return text;
|
|
30
|
-
}
|
|
8
|
+
${e}`:e}return t}function y(n){return n.filter(t=>t.role==="user"||t.role==="assistant").map(t=>({role:t.role,content:h(t)}))}function g(n){return(n.attachments||[]).filter(t=>t.kind==="image"&&t.dataUrl)}function k(n,t){return n.filter(i=>i.role==="user"||i.role==="assistant").map(i=>{const e=h(i),r=i.role==="user"?g(i):[];if(r.length===0)return{role:i.role,content:e};if(t==="anthropic"){const a=[];for(const s of r){const o=/^data:([^;]+);base64,(.+)$/s.exec(s.dataUrl);o&&a.push({type:"image",source:{type:"base64",media_type:o[1],data:o[2]}})}return e&&a.push({type:"text",text:e}),{role:"user",content:a.length?a:e}}const c=[];e&&c.push({type:"text",text:e});for(const a of r)c.push({type:"image_url",image_url:{url:a.dataUrl}});return{role:"user",content:c.length?c:e}})}function $(n,t,{minTokens:i=700}={}){if(!t?.specs?.length)return null;const e=[];for(const s of n||[])for(const o of s?.attachments||[])o?.kind==="image"||!o?.text||e.push(o);if(!e.length)return null;const r=d(e.map(s=>({kind:s.kind||"context",title:s.title,url:s.url,text:s.text})));if(r.tokens<i)return null;const c=new Map(e.map((s,o)=>[s,r.entries[o]?.id]));return{messages:(n||[]).map(s=>s?.attachments?.some(o=>c.get(o))?{...s,attachments:s.attachments.map(o=>{const u=c.get(o);return u?{...o,text:`(not included — read with \`source\`: id ${u}, ~${p(o.text)} tokens)`}:o})}:s),store:r}}const m={name:"source",description:"Read an attached source (a page, tab, selection or file the user attached). Their content is NOT in the conversation — read what you need from here.",parameters:{type:"object",properties:{id:{type:"string",description:"The source id from the manifest, e.g. page-1."},query:{type:"string",description:"What you are looking for. A large source returns the matching sections rather than its first page."}},required:["id"]}};function S(n,t){const i={...m},e=[n?.system,`${l(t)}
|
|
31
9
|
|
|
32
|
-
|
|
33
|
-
return messages
|
|
34
|
-
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
35
|
-
.map((m) => ({ role: m.role, content: renderContent(m) }));
|
|
36
|
-
}
|
|
10
|
+
Their content is NOT in this conversation. Call \`source\` with an id — and a query when the source is large — to read what you need.`].filter(Boolean).join(`
|
|
37
11
|
|
|
38
|
-
|
|
39
|
-
export function imageAttachmentsOf(m) {
|
|
40
|
-
return (m.attachments || []).filter((a) => a.kind === 'image' && a.dataUrl);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Like toChatMessages, but emits multimodal content (text + image blocks) for
|
|
44
|
-
// user messages that carry images, in the given provider's wire format. Falls
|
|
45
|
-
// back to plain string content when there are no images. `provider` is
|
|
46
|
-
// 'openai' | 'anthropic'.
|
|
47
|
-
export function toMultimodalMessages(messages, provider) {
|
|
48
|
-
return messages
|
|
49
|
-
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
50
|
-
.map((m) => {
|
|
51
|
-
const text = renderContent(m);
|
|
52
|
-
const imgs = m.role === 'user' ? imageAttachmentsOf(m) : [];
|
|
53
|
-
if (imgs.length === 0) return { role: m.role, content: text };
|
|
54
|
-
if (provider === 'anthropic') {
|
|
55
|
-
const content = [];
|
|
56
|
-
for (const a of imgs) {
|
|
57
|
-
const match = /^data:([^;]+);base64,(.+)$/s.exec(a.dataUrl);
|
|
58
|
-
if (match) content.push({ type: 'image', source: { type: 'base64', media_type: match[1], data: match[2] } });
|
|
59
|
-
}
|
|
60
|
-
if (text) content.push({ type: 'text', text });
|
|
61
|
-
return { role: 'user', content: content.length ? content : text };
|
|
62
|
-
}
|
|
63
|
-
// openai (and OpenAI-compatible vision endpoints)
|
|
64
|
-
const content = [];
|
|
65
|
-
if (text) content.push({ type: 'text', text });
|
|
66
|
-
for (const a of imgs) content.push({ type: 'image_url', image_url: { url: a.dataUrl } });
|
|
67
|
-
return { role: 'user', content: content.length ? content : text };
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Hand the model a MANIFEST of what is attached, and a tool to read it.
|
|
73
|
-
*
|
|
74
|
-
* Attachments used to be flattened into the first message — every attached tab, in full,
|
|
75
|
-
* before the model had said anything. "hi" on a long page paid for the whole page, and five
|
|
76
|
-
* attached tabs put five documents in the prompt to answer a question about one paragraph of
|
|
77
|
-
* one of them.
|
|
78
|
-
*
|
|
79
|
-
* Two conditions, both necessary:
|
|
80
|
-
* - THE TURN MUST CARRY TOOLS. Deferring content a model cannot then fetch does not save
|
|
81
|
-
* tokens, it deletes the context — the worst possible outcome, and silently.
|
|
82
|
-
* - IT MUST BE WORTH A ROUND TRIP. Below the threshold the extra call costs more than the
|
|
83
|
-
* text it avoids, so small attachments still travel inline.
|
|
84
|
-
*
|
|
85
|
-
* Returns the rewritten messages plus a store, or null when nothing was deferred.
|
|
86
|
-
*/
|
|
87
|
-
export function deferAttachedSources(messages, tools, { minTokens = 700 } = {}) {
|
|
88
|
-
if (!tools?.specs?.length) return null;
|
|
89
|
-
const carried = [];
|
|
90
|
-
for (const m of messages || []) {
|
|
91
|
-
for (const a of m?.attachments || []) {
|
|
92
|
-
if (a?.kind === 'image' || !a?.text) continue;
|
|
93
|
-
carried.push(a);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (!carried.length) return null;
|
|
97
|
-
const store = makeSourceStore(carried.map((a) => ({
|
|
98
|
-
kind: a.kind || 'context', title: a.title, url: a.url, text: a.text,
|
|
99
|
-
})));
|
|
100
|
-
if (store.tokens < minTokens) return null;
|
|
101
|
-
// Same index, same id: the manifest and the store must agree or the model asks for
|
|
102
|
-
// something real and is told it does not exist.
|
|
103
|
-
const idFor = new Map(carried.map((a, i) => [a, store.entries[i]?.id]));
|
|
104
|
-
const out = (messages || []).map((m) => {
|
|
105
|
-
if (!m?.attachments?.some((a) => idFor.get(a))) return m;
|
|
106
|
-
return {
|
|
107
|
-
...m,
|
|
108
|
-
attachments: m.attachments.map((a) => {
|
|
109
|
-
const id = idFor.get(a);
|
|
110
|
-
// The stub keeps the title and url — knowing WHAT is attached is what lets the model
|
|
111
|
-
// decide whether to read it, and that part is cheap.
|
|
112
|
-
return id ? { ...a, text: `(not included — read with \`source\`: id ${id}, ~${approxTokens(a.text)} tokens)` } : a;
|
|
113
|
-
}),
|
|
114
|
-
};
|
|
115
|
-
});
|
|
116
|
-
return { messages: out, store };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export const SOURCE_TOOL_SPEC = {
|
|
120
|
-
name: 'source',
|
|
121
|
-
description: 'Read an attached source (a page, tab, selection or file the user attached). Their content is NOT in the conversation — read what you need from here.',
|
|
122
|
-
parameters: {
|
|
123
|
-
type: 'object',
|
|
124
|
-
properties: {
|
|
125
|
-
id: { type: 'string', description: 'The source id from the manifest, e.g. page-1.' },
|
|
126
|
-
query: { type: 'string', description: 'What you are looking for. A large source returns the matching sections rather than its first page.' },
|
|
127
|
-
},
|
|
128
|
-
required: ['id'],
|
|
129
|
-
},
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
/** Add `source` to an existing toolset without disturbing what is already there. */
|
|
133
|
-
export function withSourceTool(tools, store) {
|
|
134
|
-
const spec = { ...SOURCE_TOOL_SPEC };
|
|
135
|
-
const system = [
|
|
136
|
-
tools?.system,
|
|
137
|
-
`${manifestText(store)}\n\nTheir content is NOT in this conversation. Call \`source\` with an id — and a query when the source is large — to read what you need.`,
|
|
138
|
-
].filter(Boolean).join('\n\n');
|
|
139
|
-
return {
|
|
140
|
-
...tools,
|
|
141
|
-
specs: [...(tools?.specs || []), spec],
|
|
142
|
-
system,
|
|
143
|
-
systemParts: { ...(tools?.systemParts || {}), source: approxTokens(manifestText(store)) },
|
|
144
|
-
execute: async (name, input, meta) => (name === 'source'
|
|
145
|
-
? JSON.stringify(readSource(store, typeof input === 'string' ? JSON.parse(input || '{}') : (input || {})))
|
|
146
|
-
: tools.execute(name, input, meta)),
|
|
147
|
-
};
|
|
148
|
-
}
|
|
12
|
+
`);return{...n,specs:[...n?.specs||[],i],system:e,systemParts:{...n?.systemParts||{},source:p(l(t))},execute:async(r,c,a)=>r==="source"?JSON.stringify(f(t,typeof c=="string"?JSON.parse(c||"{}"):c||{})):n.execute(r,c,a)}}export{m as SOURCE_TOOL_SPEC,$ as deferAttachedSources,g as imageAttachmentsOf,h as renderContent,y as toChatMessages,k as toMultimodalMessages,S as withSourceTool};
|