@chatpanel/events 0.69.0 → 0.70.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/context-attachments.js +148 -0
- package/find-tool.js +12 -5
- package/index.js +3 -0
- package/package.json +6 -2
- package/weather-tool.js +65 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// ATTACHED CONTEXT, AS THE MODEL SEES IT — the same on every client.
|
|
2
|
+
//
|
|
3
|
+
// A message carries `attachments`: pages, selections, fetched links, files, images, and the
|
|
4
|
+
// user's own records. This is how they reach a model: text sources folded into the message
|
|
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.
|
|
11
|
+
|
|
12
|
+
import { makeSourceStore, manifestText, readSource, approxTokens } from './sources-retrieval.js';
|
|
13
|
+
|
|
14
|
+
// Flatten a stored message (with attachments) into the text the model sees.
|
|
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
|
+
}
|
|
31
|
+
|
|
32
|
+
export function toChatMessages(messages) {
|
|
33
|
+
return messages
|
|
34
|
+
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
35
|
+
.map((m) => ({ role: m.role, content: renderContent(m) }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Image attachments on a message: { dataUrl: 'data:<media>;base64,<...>' }.
|
|
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
|
+
}
|
package/find-tool.js
CHANGED
|
@@ -34,11 +34,18 @@ export const FIND_DESCRIPTION =
|
|
|
34
34
|
// meeting history" — while `find` was sitting in its toolset. The old line named the tool
|
|
35
35
|
// and left the capability to be inferred, and inference is what small models are worst at.
|
|
36
36
|
export const FIND_RESIDENT =
|
|
37
|
-
"You HAVE access to the user's own ChatPanel data —
|
|
38
|
-
+ '
|
|
39
|
-
+ '
|
|
40
|
-
+ '
|
|
41
|
-
|
|
37
|
+
"You HAVE access to the user's own ChatPanel data — past chats, notes, meeting "
|
|
38
|
+
+ 'transcripts and summaries — through `find`, plus the web. For anything they discussed or '
|
|
39
|
+
+ 'wrote, call `find` FIRST and answer from it; never say you cannot access their meetings, '
|
|
40
|
+
+ 'notes or history. '
|
|
41
|
+
// Named here, resident, because a relayed agent with a web search of its own otherwise
|
|
42
|
+
// reaches for that: the desktop asked Codex about the weather, Codex searched on its own,
|
|
43
|
+
// read pages that were scripts and no temperature, and answered that it could not tell.
|
|
44
|
+
// The `weather` action answers in one request. And a `find` call is shown to the user as
|
|
45
|
+
// a step — an agent's own search is not. The whole block stays under the extension's
|
|
46
|
+
// 120-token resident cap (test-data-dispatch.mjs): the manual travels with `describe`.
|
|
47
|
+
+ 'For anything current — weather, prices, news — use `find` (actions `weather`, '
|
|
48
|
+
+ '`web_search`), not a search tool of your own; the user sees `find` calls as steps.';
|
|
42
49
|
|
|
43
50
|
/**
|
|
44
51
|
* Wrap the real search/read tools (history, web search, weather…) as the one `find` tool.
|
package/index.js
CHANGED
|
@@ -283,3 +283,6 @@ export { McpClient, mcpProvider } from './mcp-client.js';
|
|
|
283
283
|
export { mcpSharedSystem, mcpInventorySystem, sourceCitationSystem, combineSystemPrompt, toolStatus, widgetAuthoringSystem, vaultWidgetSystem, wantsVaultGuidance } from './tool-hints.js';
|
|
284
284
|
export { adaptiveToolRetryHint, createAdaptiveToolPolicy, isInvalidToolParametersResult } from './adaptive-tool-policy.js';
|
|
285
285
|
export { getMcpProviders, testMcpServer, resetMcp } from './mcp-manager.js';
|
|
286
|
+
export { WEATHER_TOOL_NAME, WEATHER_TOOL_SYSTEM, weatherToolProvider } from './weather-tool.js';
|
|
287
|
+
// Attached context as the model sees it — <context> blocks, image blocks, deferred sources.
|
|
288
|
+
export { renderContent, toChatMessages, toMultimodalMessages, imageAttachmentsOf, deferAttachedSources, withSourceTool, SOURCE_TOOL_SPEC } from './context-attachments.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.0",
|
|
4
4
|
"description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -105,7 +105,9 @@
|
|
|
105
105
|
"./tool-hints.js": "./tool-hints.js",
|
|
106
106
|
"./adaptive-tool-policy.js": "./adaptive-tool-policy.js",
|
|
107
107
|
"./mcp-client.js": "./mcp-client.js",
|
|
108
|
-
"./mcp-manager.js": "./mcp-manager.js"
|
|
108
|
+
"./mcp-manager.js": "./mcp-manager.js",
|
|
109
|
+
"./weather-tool.js": "./weather-tool.js",
|
|
110
|
+
"./context-attachments.js": "./context-attachments.js"
|
|
109
111
|
},
|
|
110
112
|
"files": [
|
|
111
113
|
"LICENSE",
|
|
@@ -116,6 +118,7 @@
|
|
|
116
118
|
"capability.js",
|
|
117
119
|
"citations.js",
|
|
118
120
|
"client-prefs.js",
|
|
121
|
+
"context-attachments.js",
|
|
119
122
|
"cowriter-router.js",
|
|
120
123
|
"cowriter.js",
|
|
121
124
|
"curate.js",
|
|
@@ -203,6 +206,7 @@
|
|
|
203
206
|
"vault.js",
|
|
204
207
|
"view.js",
|
|
205
208
|
"voice-intents.js",
|
|
209
|
+
"weather-tool.js",
|
|
206
210
|
"weather.js",
|
|
207
211
|
"web-search-tool.js",
|
|
208
212
|
"web-search.js",
|
package/weather-tool.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// `weather` as a TOOL — the spec, the guidance, and the answer shape, without the fetch.
|
|
2
|
+
//
|
|
3
|
+
// The extension answers "how is the weather in X" with this rather than with a web search,
|
|
4
|
+
// because weather sites are rendered by scripts and a fetched page is navigation and no
|
|
5
|
+
// temperature — which is exactly what a desktop turn without this tool produced: "I couldn't
|
|
6
|
+
// retrieve reliable live weather". The engine (`weather.js`: the wttr.in query, the parse,
|
|
7
|
+
// the ambiguity check) is already shared; this is the tool every client arms in front of it.
|
|
8
|
+
//
|
|
9
|
+
// `fetchJson(url, { timeoutMs })` is injected: each host owns its network guard and applies
|
|
10
|
+
// it to a URL a MODEL supplied — attacker-influenced by construction.
|
|
11
|
+
|
|
12
|
+
import { getWeather } from './weather.js';
|
|
13
|
+
|
|
14
|
+
export const WEATHER_TOOL_NAME = 'weather';
|
|
15
|
+
|
|
16
|
+
export const WEATHER_TOOL_SYSTEM =
|
|
17
|
+
'For weather, call `weather` FIRST — it answers the whole question in one request. Only '
|
|
18
|
+
+ 'fall back to web_search if it tells you to. Report the location it says it resolved, '
|
|
19
|
+
+ 'because a bare town name can geocode to the wrong place.';
|
|
20
|
+
|
|
21
|
+
export function weatherToolProvider({ fetchJson } = {}) {
|
|
22
|
+
if (typeof fetchJson !== 'function') throw new Error('weatherToolProvider: fetchJson required');
|
|
23
|
+
return {
|
|
24
|
+
specs: [
|
|
25
|
+
{
|
|
26
|
+
name: 'weather',
|
|
27
|
+
// A read, declared: the round runner overlaps reads and serialises everything it
|
|
28
|
+
// cannot classify, and "weather" is not a verb its name heuristic knows.
|
|
29
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
30
|
+
description:
|
|
31
|
+
'Current conditions and a short forecast for one place, in a single request. Use this '
|
|
32
|
+
+ 'for any weather question instead of searching. Returns the location it actually '
|
|
33
|
+
+ 'resolved to — say which place the answer is for.',
|
|
34
|
+
parameters: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
location: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'A place, as the user said it. Add a state or country only if THEY did '
|
|
40
|
+
+ '— "Fairview, OR" if they said so, plain "Fairview" if they did not.',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
required: ['location'],
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
system: WEATHER_TOOL_SYSTEM,
|
|
49
|
+
async execute(name, input) {
|
|
50
|
+
if (name !== 'weather') return JSON.stringify({ error: `Unknown tool: ${name}` });
|
|
51
|
+
const location = String(input?.location || '').trim();
|
|
52
|
+
if (!location) return 'No location provided to weather.';
|
|
53
|
+
const got = await getWeather(location, { fetchJson });
|
|
54
|
+
// THE FALLBACK IS AN INSTRUCTION, not an error string. A model handed "weather failed"
|
|
55
|
+
// stops, or apologises; a model told which tool answers this next just uses it. The
|
|
56
|
+
// whole point of preferring one source is that it must degrade to the general one.
|
|
57
|
+
if (!got.ok) {
|
|
58
|
+
return `The weather service could not answer for "${location}" (${got.reason}). `
|
|
59
|
+
+ `Now call web_search for "weather in ${location}" and answer from the results — `
|
|
60
|
+
+ 'do not tell the user a tool failed.';
|
|
61
|
+
}
|
|
62
|
+
return { text: got.text, note: 'ChatPanel · wttr.in' };
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|