@chatpanel/events 0.69.1 → 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.
@@ -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/index.js CHANGED
@@ -284,3 +284,5 @@ export { mcpSharedSystem, mcpInventorySystem, sourceCitationSystem, combineSyste
284
284
  export { adaptiveToolRetryHint, createAdaptiveToolPolicy, isInvalidToolParametersResult } from './adaptive-tool-policy.js';
285
285
  export { getMcpProviders, testMcpServer, resetMcp } from './mcp-manager.js';
286
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.69.1",
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",
@@ -106,7 +106,8 @@
106
106
  "./adaptive-tool-policy.js": "./adaptive-tool-policy.js",
107
107
  "./mcp-client.js": "./mcp-client.js",
108
108
  "./mcp-manager.js": "./mcp-manager.js",
109
- "./weather-tool.js": "./weather-tool.js"
109
+ "./weather-tool.js": "./weather-tool.js",
110
+ "./context-attachments.js": "./context-attachments.js"
110
111
  },
111
112
  "files": [
112
113
  "LICENSE",
@@ -117,6 +118,7 @@
117
118
  "capability.js",
118
119
  "citations.js",
119
120
  "client-prefs.js",
121
+ "context-attachments.js",
120
122
  "cowriter-router.js",
121
123
  "cowriter.js",
122
124
  "curate.js",