@abide/abide 0.115.0 → 0.116.1
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/CHANGELOG.md +19 -0
- package/docs/REGISTRY.md +17 -14
- package/docs/RULEBOOK.md +41 -5
- package/docs/abide.md +74 -22
- package/package.json +1 -1
- package/src/compiler/classifyTemplate.ts +21 -14
- package/src/compiler/emitClient.ts +5 -1
- package/src/server/BRIDGE_VERSION.ts +1 -1
- package/src/server/IMAGE_TYPES.ts +10 -0
- package/src/server/OFFLINE_STALE.ts +4 -3
- package/src/server/answerWorker.ts +85 -22
- package/src/server/assistant.ts +81 -12
- package/src/server/claudeApi.ts +25 -20
- package/src/server/claudeCode.ts +30 -10
- package/src/server/generatedSurfaces.ts +48 -14
- package/src/server/index.ts +2 -0
- package/src/shared/OFFLINE_CACHES.ts +4 -1
- package/src/shared/REACHED_MESSAGE.ts +7 -0
- package/src/shared/framing.ts +10 -2
- package/src/ui/Router.ts +28 -7
- package/src/ui/bindFiles.ts +42 -0
- package/src/ui/index.ts +1 -0
|
@@ -15,8 +15,10 @@ import {
|
|
|
15
15
|
OFFLINE_BUNDLE,
|
|
16
16
|
OFFLINE_CACHE_PREFIX,
|
|
17
17
|
OFFLINE_SAVED,
|
|
18
|
+
OFFLINE_SHARED,
|
|
18
19
|
} from '#shared/OFFLINE_CACHES.ts'
|
|
19
20
|
import { PRINCIPAL_HEADER } from '#shared/PRINCIPAL_HEADER.ts'
|
|
21
|
+
import { REACHED_MESSAGE } from '#shared/REACHED_MESSAGE.ts'
|
|
20
22
|
import type { Manifest } from './manifestOf.ts'
|
|
21
23
|
|
|
22
24
|
// What `framingOf` reads as a framed stream (16.32), and a binary's bytes (16.34), which may be one —
|
|
@@ -30,6 +32,7 @@ const STREAMED_TYPES = [
|
|
|
30
32
|
|
|
31
33
|
const CACHES = `const OURS = ${JSON.stringify(OFFLINE_CACHE_PREFIX)}
|
|
32
34
|
const SAVED = ${JSON.stringify(OFFLINE_SAVED)}
|
|
35
|
+
const SHARED = ${JSON.stringify(OFFLINE_SHARED)}
|
|
33
36
|
const BUNDLE = ${JSON.stringify(OFFLINE_BUNDLE)}
|
|
34
37
|
|
|
35
38
|
async function dropOurs(keeping) {
|
|
@@ -38,7 +41,7 @@ async function dropOurs(keeping) {
|
|
|
38
41
|
}
|
|
39
42
|
`
|
|
40
43
|
|
|
41
|
-
// 45.3 through 45.8, 45.13,
|
|
44
|
+
// 45.3 through 45.8, 45.13, 45.17 through 45.21, and 45.24.
|
|
42
45
|
const SAVING = `
|
|
43
46
|
self.addEventListener('install', (event) => {
|
|
44
47
|
event.waitUntil(
|
|
@@ -60,26 +63,23 @@ self.addEventListener('activate', (event) => {
|
|
|
60
63
|
await self.registration.navigationPreload?.disable()
|
|
61
64
|
await self.clients.claim()
|
|
62
65
|
for (const client of await self.clients.matchAll({ type: 'window' }))
|
|
63
|
-
await
|
|
64
|
-
(response) =>
|
|
65
|
-
kept(response)
|
|
66
|
-
? keep(new Request(client.url), response)
|
|
67
|
-
: response.body?.cancel(),
|
|
68
|
-
() => {},
|
|
69
|
-
)
|
|
66
|
+
await keepPage(client.url)
|
|
70
67
|
})(),
|
|
71
68
|
)
|
|
72
69
|
})
|
|
73
70
|
|
|
74
71
|
self.addEventListener('message', (event) => {
|
|
75
|
-
if (event.data === 'rotated') event.waitUntil(
|
|
72
|
+
if (event.data === 'rotated') event.waitUntil(dropSaved())
|
|
73
|
+
const reached = event.data?.[REACHED]
|
|
74
|
+
if (typeof reached === 'string' && reached.startsWith(\`\${self.location.origin}/\`))
|
|
75
|
+
event.waitUntil(keepReached(reached))
|
|
76
76
|
})
|
|
77
77
|
|
|
78
78
|
self.addEventListener('fetch', (event) => {
|
|
79
79
|
const request = event.request
|
|
80
80
|
if (request.mode === 'navigate') {
|
|
81
81
|
if (request.method === 'GET') event.respondWith(answer(event, true))
|
|
82
|
-
else event.waitUntil(
|
|
82
|
+
else event.waitUntil(dropSaved())
|
|
83
83
|
return
|
|
84
84
|
}
|
|
85
85
|
if (request.method !== 'GET') return
|
|
@@ -114,7 +114,7 @@ async function answer(event, page) {
|
|
|
114
114
|
request.cache === 'no-store')
|
|
115
115
|
const saved = bypass
|
|
116
116
|
? undefined
|
|
117
|
-
: await
|
|
117
|
+
: await savedCopy(request)
|
|
118
118
|
const life = saved === undefined ? undefined : lifeOf(saved)
|
|
119
119
|
if (life !== undefined) {
|
|
120
120
|
if (life.age < life.fresh) return marked(saved)
|
|
@@ -132,8 +132,7 @@ async function answer(event, page) {
|
|
|
132
132
|
try {
|
|
133
133
|
return await fromNetwork(event, page, page)
|
|
134
134
|
} catch {
|
|
135
|
-
if (life !== undefined && life
|
|
136
|
-
return marked(saved)
|
|
135
|
+
if (life !== undefined && standsIn(life)) return marked(saved)
|
|
137
136
|
return page ? offline() : Response.error()
|
|
138
137
|
}
|
|
139
138
|
}
|
|
@@ -169,18 +168,49 @@ async function fromNetwork(event, page, opened) {
|
|
|
169
168
|
const asked = page
|
|
170
169
|
? pageRequest(request.url, request.headers, opened ? OPENED : '1')
|
|
171
170
|
: new Request(request, { cache: 'no-cache' })
|
|
171
|
+
const since = drops
|
|
172
172
|
const response = await fetch(asked)
|
|
173
173
|
// A copy nothing reads is a tee holding every chunk, so one is cloned only to be saved.
|
|
174
174
|
const copy = kept(response) ? response.clone() : undefined
|
|
175
|
-
|
|
176
|
-
event.waitUntil(
|
|
177
|
-
(rotated ? caches.delete(SAVED) : Promise.resolve()).then(() =>
|
|
178
|
-
copy === undefined ? undefined : keep(request, copy),
|
|
179
|
-
),
|
|
180
|
-
)
|
|
175
|
+
event.waitUntil(keep(request, copy, since, response.headers.has(PRINCIPAL)))
|
|
181
176
|
return response
|
|
182
177
|
}
|
|
183
178
|
|
|
179
|
+
// 45.13, 45.24 — A PAGE NO NAVIGATION ASKED FOR, kept as a page behind the one opened is: an open
|
|
180
|
+
// window's as the worker activates, and an address the router reached.
|
|
181
|
+
function keepPage(url) {
|
|
182
|
+
const since = drops
|
|
183
|
+
return fetch(pageRequest(url, undefined, '1')).then((response) => {
|
|
184
|
+
// Nothing reads this answer, so one 45.3 or 45.4 turns away is let go here.
|
|
185
|
+
const copy = kept(response) ? response : undefined
|
|
186
|
+
if (copy === undefined) response.body?.cancel()
|
|
187
|
+
return keep(new Request(url), copy, since, response.headers.has(PRINCIPAL))
|
|
188
|
+
}, () => {})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The addresses being kept, so one reached twice before its copy lands is asked for once.
|
|
192
|
+
const reaching = new Set()
|
|
193
|
+
|
|
194
|
+
// 45.24 — AN ADDRESS THE ROUTER REACHED, whose page no navigation asked for, so none is saved unless
|
|
195
|
+
// this asks — and only where no copy saved would stand in offline: one that would is refreshed by the
|
|
196
|
+
// page's own next load, and asking on every step renders each page twice. A page kept holds no seed
|
|
197
|
+
// (RULEBOOK 41.29), so what a reload paints stale is markup its calls then answer over.
|
|
198
|
+
async function keepReached(url) {
|
|
199
|
+
if (reaching.has(url)) return
|
|
200
|
+
reaching.add(url)
|
|
201
|
+
try {
|
|
202
|
+
const saved = await savedCopy(url)
|
|
203
|
+
if (saved === undefined || !standsIn(lifeOf(saved))) await keepPage(url)
|
|
204
|
+
} finally {
|
|
205
|
+
reaching.delete(url)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 45.19 — whether a copy kept still stands in where the network gives nothing.
|
|
210
|
+
function standsIn(life) {
|
|
211
|
+
return life.age < life.fresh + life.error
|
|
212
|
+
}
|
|
213
|
+
|
|
184
214
|
// How old a kept copy is and how long each of its windows runs, in seconds; a copy with no date is
|
|
185
215
|
// as old as it can be.
|
|
186
216
|
function lifeOf(response) {
|
|
@@ -219,9 +249,41 @@ function kept(response) {
|
|
|
219
249
|
return !STREAMED.some((streamed) => type.startsWith(streamed))
|
|
220
250
|
}
|
|
221
251
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
252
|
+
// The copy saved of an address, a caller's own ahead of one any caller may be answered.
|
|
253
|
+
async function savedCopy(request) {
|
|
254
|
+
return (
|
|
255
|
+
(await caches.match(request, { cacheName: SAVED, ignoreVary: true })) ??
|
|
256
|
+
(await caches.match(request, { cacheName: SHARED, ignoreVary: true }))
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 45.7 — EVERY PAGE SAVED DROPPED, and with them every copy asked for before the drop: one asked under
|
|
261
|
+
// the cookies of then would land after it holding the caller signed out since. The drop an answer's
|
|
262
|
+
// own abide-principal makes is the exception, since that answer is the new caller's. What any caller
|
|
263
|
+
// may be answered is kept apart and left standing: it was never the caller's to drop, and a font
|
|
264
|
+
// thrown away at sign-in is a page drawn without it offline.
|
|
265
|
+
let drops = 0
|
|
266
|
+
|
|
267
|
+
function dropSaved() {
|
|
268
|
+
drops += 1
|
|
269
|
+
return caches.delete(SAVED)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Saves a copy 45.3 and 45.4 let through where no drop has come since its request was asked — after
|
|
273
|
+
// the drop its own answer makes where that answer rotated the principal — and cancels one where one has.
|
|
274
|
+
// One any caller may be answered is saved whenever it was asked, since no caller's cookies made it.
|
|
275
|
+
async function keep(request, copy, since, rotated) {
|
|
276
|
+
if (rotated) {
|
|
277
|
+
await dropSaved()
|
|
278
|
+
since = drops
|
|
279
|
+
}
|
|
280
|
+
if (copy === undefined) return
|
|
281
|
+
const shared = directivesOf(copy).has('public')
|
|
282
|
+
if (!shared && since !== drops) return copy.body?.cancel()
|
|
283
|
+
// An address whose answer went public or private since leaves no copy behind for savedCopy to
|
|
284
|
+
// prefer over this one.
|
|
285
|
+
await (await caches.open(shared ? SAVED : SHARED)).delete(request)
|
|
286
|
+
await (await caches.open(shared ? SHARED : SAVED)).put(request, copy)
|
|
225
287
|
}
|
|
226
288
|
|
|
227
289
|
function offline() {
|
|
@@ -277,6 +339,7 @@ const KEPT = ${JSON.stringify(KEPT_HEADER)}
|
|
|
277
339
|
const KEEP = ${JSON.stringify(KEEP_HEADER)}
|
|
278
340
|
const OPENED = ${JSON.stringify(KEEP_OPENED)}
|
|
279
341
|
const NAVIGATION = ${JSON.stringify(NAVIGATION_HEADER)}
|
|
342
|
+
const REACHED = ${JSON.stringify(REACHED_MESSAGE)}
|
|
280
343
|
// 45.4 — the types a stream 16.32 frames, which has no end to save.
|
|
281
344
|
const STREAMED = ${JSON.stringify(STREAMED_TYPES)}
|
|
282
345
|
const OFFLINE = ${JSON.stringify(OFFLINE)}
|
package/src/server/assistant.ts
CHANGED
|
@@ -12,11 +12,20 @@
|
|
|
12
12
|
|
|
13
13
|
import { namedLogger } from '#shared/log.ts'
|
|
14
14
|
import { refuse } from '#shared/refuse.ts'
|
|
15
|
+
import { IMAGE_TYPES } from './IMAGE_TYPES.ts'
|
|
15
16
|
import { type McpReply, McpSurface } from './generatedSurfaces.ts'
|
|
16
17
|
import { request, within } from './requestScope.ts'
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
export type
|
|
19
|
+
/** A piece of a turn: words, or an image as base64 with its type. */
|
|
20
|
+
export type AssistantContent =
|
|
21
|
+
| { type: 'text'; text: string }
|
|
22
|
+
| { type: 'image'; data: string; mimeType: string }
|
|
23
|
+
|
|
24
|
+
/** One turn of the transcript a caller sends: its words, or its pieces in order. */
|
|
25
|
+
export type AssistantMessage = {
|
|
26
|
+
role: 'user' | 'assistant'
|
|
27
|
+
content: string | AssistantContent[]
|
|
28
|
+
}
|
|
20
29
|
|
|
21
30
|
/** What a conversation yields, the same from every provider. */
|
|
22
31
|
export type AssistantEvent =
|
|
@@ -43,19 +52,19 @@ export type ToolDefinition = {
|
|
|
43
52
|
outputSchema: unknown
|
|
44
53
|
}
|
|
45
54
|
|
|
55
|
+
/** What a tool call answered: its pieces, and whether it failed. 20.21, 44.35. */
|
|
56
|
+
export type ToolAnswer = { content: AssistantContent[]; failed: boolean }
|
|
57
|
+
|
|
46
58
|
/** The app's MCP tools, called as the asker. */
|
|
47
59
|
export type Toolbox = {
|
|
48
60
|
list: ToolDefinition[]
|
|
49
|
-
call(
|
|
50
|
-
name: string,
|
|
51
|
-
input: unknown,
|
|
52
|
-
): Promise<{ text: string; failed: boolean }>
|
|
61
|
+
call(name: string, input: unknown): Promise<ToolAnswer>
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
/** What runs a conversation: a model's API, or something that runs its own loop. */
|
|
56
65
|
export type Provider = {
|
|
57
66
|
run(
|
|
58
|
-
messages: AssistantMessage[],
|
|
67
|
+
messages: (AssistantMessage & { content: AssistantContent[] })[],
|
|
59
68
|
tools: Toolbox,
|
|
60
69
|
limits: { maxTurns: number; signal: AbortSignal },
|
|
61
70
|
): AsyncGenerator<AssistantEvent>
|
|
@@ -81,11 +90,38 @@ export async function* assistant(
|
|
|
81
90
|
400,
|
|
82
91
|
'The transcript has to end on a user turn for the assistant to answer it. RULEBOOK 44.3.',
|
|
83
92
|
)
|
|
93
|
+
// Every turn as its pieces, so a provider reads one shape: words alone are one text piece.
|
|
94
|
+
const turns: Parameters<Provider['run']>[0] = []
|
|
95
|
+
for (const message of messages) {
|
|
96
|
+
if (typeof message.content === 'string') {
|
|
97
|
+
turns.push({
|
|
98
|
+
role: message.role,
|
|
99
|
+
content: [{ type: 'text', text: message.content }],
|
|
100
|
+
})
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
turns.push({ role: message.role, content: message.content })
|
|
104
|
+
for (const piece of message.content) {
|
|
105
|
+
if (piece?.type !== 'image') continue
|
|
106
|
+
// 44.37 — an image is the asker's to show, never a turn the model answered.
|
|
107
|
+
if (message.role !== 'user')
|
|
108
|
+
throw refuse(
|
|
109
|
+
400,
|
|
110
|
+
'An image can only stand in a user turn of the transcript. RULEBOOK 44.37.',
|
|
111
|
+
)
|
|
112
|
+
// 44.36.
|
|
113
|
+
if (!IMAGE_TYPES.has(piece.mimeType))
|
|
114
|
+
throw refuse(
|
|
115
|
+
400,
|
|
116
|
+
`An image in the transcript is ${piece.mimeType}, and a model is shown only ${[...IMAGE_TYPES].join(', ')}. RULEBOOK 44.36.`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
84
120
|
const tools = toolboxOver(
|
|
85
121
|
options.mcp === true ? appSurface() : NO_SURFACE,
|
|
86
122
|
askerOf(request()),
|
|
87
123
|
)
|
|
88
|
-
const events = provider.run(
|
|
124
|
+
const events = provider.run(turns, tools, {
|
|
89
125
|
maxTurns: options.maxTurns ?? MAX_TURNS,
|
|
90
126
|
signal: request().signal,
|
|
91
127
|
})
|
|
@@ -181,10 +217,15 @@ function toolboxOver(surface: McpSurface, asker: Request): Toolbox {
|
|
|
181
217
|
return await surface.call(name, input, asker)
|
|
182
218
|
} catch (thrown) {
|
|
183
219
|
return {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
:
|
|
220
|
+
content: [
|
|
221
|
+
{
|
|
222
|
+
type: 'text',
|
|
223
|
+
text:
|
|
224
|
+
thrown instanceof Error
|
|
225
|
+
? thrown.message
|
|
226
|
+
: String(thrown),
|
|
227
|
+
},
|
|
228
|
+
],
|
|
188
229
|
failed: true,
|
|
189
230
|
}
|
|
190
231
|
}
|
|
@@ -199,3 +240,31 @@ function toolboxOver(surface: McpSurface, asker: Request): Toolbox {
|
|
|
199
240
|
)
|
|
200
241
|
return tools
|
|
201
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Pieces as Messages API blocks, in order: text as text, an image as base64 with its type. A text of
|
|
246
|
+
* whitespace alone is left out, as the API refuses one. 44.38.
|
|
247
|
+
*/
|
|
248
|
+
export function blocksOf(content: AssistantContent[]): unknown[] {
|
|
249
|
+
const blocks: unknown[] = []
|
|
250
|
+
for (const piece of content)
|
|
251
|
+
if (piece.type === 'image')
|
|
252
|
+
blocks.push({
|
|
253
|
+
type: 'image',
|
|
254
|
+
source: {
|
|
255
|
+
type: 'base64',
|
|
256
|
+
media_type: piece.mimeType,
|
|
257
|
+
data: piece.data,
|
|
258
|
+
},
|
|
259
|
+
})
|
|
260
|
+
else if (/\S/.test(piece.text))
|
|
261
|
+
blocks.push({ type: 'text', text: piece.text })
|
|
262
|
+
return blocks
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The words of `content`, its images left out. */
|
|
266
|
+
export function textOf(content: AssistantContent[]): string {
|
|
267
|
+
let text = ''
|
|
268
|
+
for (const piece of content) if (piece.type === 'text') text += piece.text
|
|
269
|
+
return text
|
|
270
|
+
}
|
package/src/server/claudeApi.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { framedStream } from '#shared/framing.ts'
|
|
10
10
|
import { refuse } from '#shared/refuse.ts'
|
|
11
|
-
import
|
|
11
|
+
import { blocksOf, type Provider, textOf } from './assistant.ts'
|
|
12
12
|
|
|
13
13
|
/** A Messages API request body, less what abide writes, with the client's key and origin. */
|
|
14
14
|
export type ClaudeApiConfig = {
|
|
@@ -80,8 +80,12 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
|
|
|
80
80
|
input_schema: tool.inputSchema,
|
|
81
81
|
})
|
|
82
82
|
const history: { role: string; content: unknown }[] = []
|
|
83
|
+
// 44.38 — each image in its place in its turn.
|
|
83
84
|
for (const message of messages)
|
|
84
|
-
history.push({
|
|
85
|
+
history.push({
|
|
86
|
+
role: message.role,
|
|
87
|
+
content: blocksOf(message.content),
|
|
88
|
+
})
|
|
85
89
|
const usage = { input: 0, output: 0 }
|
|
86
90
|
for (let turn = 1; ; turn++) {
|
|
87
91
|
const answered = await fetch(
|
|
@@ -189,28 +193,29 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
|
|
|
189
193
|
}
|
|
190
194
|
return
|
|
191
195
|
}
|
|
192
|
-
const
|
|
193
|
-
called.map(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
block.name as string,
|
|
201
|
-
block.input,
|
|
202
|
-
)),
|
|
203
|
-
}),
|
|
204
|
-
),
|
|
196
|
+
const answers = await Promise.all(
|
|
197
|
+
called.map(async (block) => ({
|
|
198
|
+
id: block.id as string,
|
|
199
|
+
...(await tools.call(
|
|
200
|
+
block.name as string,
|
|
201
|
+
block.input,
|
|
202
|
+
)),
|
|
203
|
+
})),
|
|
205
204
|
)
|
|
206
205
|
const content: unknown[] = []
|
|
207
|
-
for (const
|
|
208
|
-
|
|
206
|
+
for (const answer of answers) {
|
|
207
|
+
// 44.35 — the event carries the answer's words; an image goes to the model alone.
|
|
208
|
+
yield {
|
|
209
|
+
type: 'result',
|
|
210
|
+
id: answer.id,
|
|
211
|
+
text: textOf(answer.content),
|
|
212
|
+
failed: answer.failed,
|
|
213
|
+
}
|
|
209
214
|
content.push({
|
|
210
215
|
type: 'tool_result',
|
|
211
|
-
tool_use_id:
|
|
212
|
-
content:
|
|
213
|
-
is_error:
|
|
216
|
+
tool_use_id: answer.id,
|
|
217
|
+
content: blocksOf(answer.content),
|
|
218
|
+
is_error: answer.failed,
|
|
214
219
|
})
|
|
215
220
|
}
|
|
216
221
|
// 44.12 — every result of one turn in one message.
|
package/src/server/claudeCode.ts
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
import { mounted } from '#shared/mount.ts'
|
|
12
12
|
import { refuse } from '#shared/refuse.ts'
|
|
13
13
|
import {
|
|
14
|
+
type AssistantContent,
|
|
14
15
|
type AssistantEvent,
|
|
15
16
|
type AssistantMessage,
|
|
17
|
+
blocksOf,
|
|
16
18
|
forwardOf,
|
|
17
19
|
type Provider,
|
|
18
20
|
} from './assistant.ts'
|
|
@@ -65,7 +67,7 @@ function claudeCodeProvider(config: ClaudeCodeConfig = {}): Provider {
|
|
|
65
67
|
bridge.frames.push({
|
|
66
68
|
type: 'turn',
|
|
67
69
|
id: turn.id,
|
|
68
|
-
prompt:
|
|
70
|
+
prompt: foldedContent(messages),
|
|
69
71
|
config,
|
|
70
72
|
maxTurns,
|
|
71
73
|
})
|
|
@@ -105,16 +107,34 @@ function claudeCodeProvider(config: ClaudeCodeConfig = {}): Provider {
|
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The transcript as the one message Claude Code is sent, as Messages API blocks: earlier turns folded
|
|
112
|
+
* above the last, and each image in its place. 44.5.
|
|
113
|
+
*/
|
|
114
|
+
function foldedContent(
|
|
115
|
+
messages: (AssistantMessage & { content: AssistantContent[] })[],
|
|
116
|
+
): unknown[] {
|
|
117
|
+
const pieces: AssistantContent[] = []
|
|
118
|
+
let words = messages.length > 1 ? '<conversation>\n' : ''
|
|
119
|
+
const last = messages.length - 1
|
|
120
|
+
for (let index = 0; index <= last; index++) {
|
|
121
|
+
const message = messages[index] as (typeof messages)[number]
|
|
122
|
+
if (index < last) words += `<${message.role}>\n`
|
|
123
|
+
for (let at = 0; at < message.content.length; at++) {
|
|
124
|
+
const piece = message.content[at] as AssistantContent
|
|
125
|
+
if (at > 0) words += '\n'
|
|
126
|
+
if (piece.type === 'text') {
|
|
127
|
+
words += piece.text
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
pieces.push({ type: 'text', text: words }, piece)
|
|
131
|
+
words = ''
|
|
132
|
+
}
|
|
133
|
+
if (index < last) words += `\n</${message.role}>\n`
|
|
134
|
+
if (index === last - 1) words += '</conversation>\n\n'
|
|
116
135
|
}
|
|
117
|
-
|
|
136
|
+
pieces.push({ type: 'text', text: words })
|
|
137
|
+
return blocksOf(pieces)
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
/** The browser asking, which a pairing is to. 44.13. */
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// schema (19.9) as `argsDocument`.
|
|
15
15
|
|
|
16
16
|
import { isFailed } from '#shared/Failed.ts'
|
|
17
|
+
import { essenceOf } from '#shared/framing.ts'
|
|
17
18
|
import { namedLogger } from '#shared/log.ts'
|
|
18
19
|
import { mounted } from '#shared/mount.ts'
|
|
19
20
|
import { type Reactive, reactive } from '#shared/reactive/index.ts'
|
|
@@ -21,9 +22,10 @@ import { parseRoutePattern } from '#shared/routePattern.ts'
|
|
|
21
22
|
import { argsInURL, rpcRequest } from '#shared/rpcRequest.ts'
|
|
22
23
|
import { answered } from './answerFailure.ts'
|
|
23
24
|
import { bodyWithin, type Mounted } from './answerRpc.ts'
|
|
24
|
-
import type { ToolDefinition } from './assistant.ts'
|
|
25
|
+
import type { ToolAnswer, ToolDefinition } from './assistant.ts'
|
|
25
26
|
import { appIdentity } from './config.ts'
|
|
26
27
|
import { crossingOrigin, crossingRefusal } from './crossingOrigin.ts'
|
|
28
|
+
import { IMAGE_TYPES } from './IMAGE_TYPES.ts'
|
|
27
29
|
import { MCP_PROTOCOL_VERSION } from './MCP_PROTOCOL_VERSION.ts'
|
|
28
30
|
import { type Listed, surfaceOf } from './surfaceOf.ts'
|
|
29
31
|
|
|
@@ -307,16 +309,12 @@ export class McpSurface {
|
|
|
307
309
|
}
|
|
308
310
|
|
|
309
311
|
/** What calling the tool `name` as `asker` answered, a refusal or a missing tool a failed one. */
|
|
310
|
-
call(
|
|
311
|
-
name: string,
|
|
312
|
-
args: unknown,
|
|
313
|
-
asker: Request,
|
|
314
|
-
): Promise<{ text: string; failed: boolean }> {
|
|
312
|
+
call(name: string, args: unknown, asker: Request): Promise<ToolAnswer> {
|
|
315
313
|
const tool = this.#tool(name)
|
|
316
314
|
if (tool === undefined) {
|
|
317
315
|
logCall(String(name), 404, undefined)
|
|
318
316
|
return Promise.resolve({
|
|
319
|
-
text: `No tool named ${name}
|
|
317
|
+
content: [{ type: 'text', text: `No tool named ${name}.` }],
|
|
320
318
|
failed: true,
|
|
321
319
|
})
|
|
322
320
|
}
|
|
@@ -331,7 +329,7 @@ export class McpSurface {
|
|
|
331
329
|
one: Published,
|
|
332
330
|
args: Record<string, unknown> | undefined,
|
|
333
331
|
asker: Request,
|
|
334
|
-
): Promise<
|
|
332
|
+
): Promise<ToolAnswer> {
|
|
335
333
|
// 28.7 — a closed namespace does not pay for the line it would have written.
|
|
336
334
|
const started = MCP_LOG.enabled() ? performance.now() : undefined
|
|
337
335
|
let answer: Response
|
|
@@ -345,10 +343,41 @@ export class McpSurface {
|
|
|
345
343
|
}
|
|
346
344
|
answer = answered(thrown)
|
|
347
345
|
}
|
|
346
|
+
const mimeType = essenceOf(answer.headers.get('content-type'))
|
|
347
|
+
if (answer.status < 400 && mimeType.startsWith('image/')) {
|
|
348
|
+
// 20.22 — a tool that promised a value and answered a picture has no structured content
|
|
349
|
+
// to carry: its handler's type is wrong, and the call says so where it happened.
|
|
350
|
+
if (structured(one)) {
|
|
351
|
+
await answer.body?.cancel()
|
|
352
|
+
logCall(one.name, 500, started)
|
|
353
|
+
return {
|
|
354
|
+
content: [
|
|
355
|
+
{
|
|
356
|
+
type: 'text',
|
|
357
|
+
text: `${one.name} declares a result schema but answered an image, ${mimeType}.`,
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
failed: true,
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// 20.21 — AN IMAGE A MODEL IS SHOWN IS THE IMAGE, handed on as one rather than its bytes
|
|
364
|
+
// read as text, so a model looks at what a handler drew. Any other (an svg) is its text.
|
|
365
|
+
if (IMAGE_TYPES.has(mimeType)) {
|
|
366
|
+
const data = (await answer.bytes()).toBase64()
|
|
367
|
+
logCall(one.name, answer.status, started)
|
|
368
|
+
return {
|
|
369
|
+
content: [{ type: 'image', data, mimeType }],
|
|
370
|
+
failed: false,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
348
374
|
// The body is the call's too: a handler that streams has only begun when its head arrives.
|
|
349
375
|
const text = await answer.text()
|
|
350
376
|
logCall(one.name, answer.status, started)
|
|
351
|
-
return {
|
|
377
|
+
return {
|
|
378
|
+
content: [{ type: 'text', text }],
|
|
379
|
+
failed: answer.status >= 400,
|
|
380
|
+
}
|
|
352
381
|
}
|
|
353
382
|
|
|
354
383
|
/** One MCP request, made as `asker`, as JSON-RPC's `result` or `error`. */
|
|
@@ -379,20 +408,25 @@ export class McpSurface {
|
|
|
379
408
|
`No tool named ${String(params?.name)}.`,
|
|
380
409
|
)
|
|
381
410
|
}
|
|
382
|
-
const {
|
|
411
|
+
const { content, failed } = await this.#called(
|
|
383
412
|
tool,
|
|
384
413
|
params?.arguments as Record<string, unknown> | undefined,
|
|
385
414
|
asker,
|
|
386
415
|
)
|
|
416
|
+
const [piece] = content
|
|
387
417
|
return {
|
|
388
418
|
result: {
|
|
389
|
-
|
|
419
|
+
// 20.21 — an AssistantContent piece is already an MCP content item.
|
|
420
|
+
content,
|
|
390
421
|
// 20.16 — the same value as structured content, where the tool declares
|
|
391
|
-
// its shape; a refusal is not that value.
|
|
422
|
+
// its shape; a refusal is not that value. A tool declaring one answers no
|
|
423
|
+
// image (20.22), so its one piece is its text.
|
|
392
424
|
structuredContent:
|
|
393
|
-
failed ||
|
|
425
|
+
failed ||
|
|
426
|
+
!structured(tool) ||
|
|
427
|
+
piece?.type !== 'text'
|
|
394
428
|
? undefined
|
|
395
|
-
: JSON.parse(text),
|
|
429
|
+
: JSON.parse(piece.text),
|
|
396
430
|
isError: failed,
|
|
397
431
|
},
|
|
398
432
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -27,11 +27,13 @@ export {
|
|
|
27
27
|
} from '#shared/rpc.ts'
|
|
28
28
|
// 45 — a conversation with a model, piped through a handler, and the two providers abide ships.
|
|
29
29
|
export {
|
|
30
|
+
type AssistantContent,
|
|
30
31
|
type AssistantEvent,
|
|
31
32
|
type AssistantMessage,
|
|
32
33
|
type AssistantOptions,
|
|
33
34
|
assistant,
|
|
34
35
|
type Provider,
|
|
36
|
+
type ToolAnswer,
|
|
35
37
|
type Toolbox,
|
|
36
38
|
type ToolDefinition,
|
|
37
39
|
} from './assistant.ts'
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
/** What every cache the worker keeps is named under, so a cache an earlier worker named is found. */
|
|
6
6
|
export const OFFLINE_CACHE_PREFIX = 'abide:'
|
|
7
7
|
|
|
8
|
-
/** The pages
|
|
8
|
+
/** The pages and the handlers' answers the worker saved, by address. */
|
|
9
9
|
export const OFFLINE_SAVED = `${OFFLINE_CACHE_PREFIX}saved`
|
|
10
10
|
|
|
11
|
+
/** What the worker saved that any caller may be answered — `cache-control: public` — by address. */
|
|
12
|
+
export const OFFLINE_SHARED = `${OFFLINE_CACHE_PREFIX}shared`
|
|
13
|
+
|
|
11
14
|
/** The build's bundle, saved as the worker installs. */
|
|
12
15
|
export const OFFLINE_BUNDLE = `${OFFLINE_CACHE_PREFIX}bundle`
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key of the message the router posts the offline worker, naming the address a navigation
|
|
3
|
+
* reached, which the worker then keeps (45.24).
|
|
4
|
+
*
|
|
5
|
+
* A leaf, because the server writes it into the worker's script and the router posts it.
|
|
6
|
+
*/
|
|
7
|
+
export const REACHED_MESSAGE = 'reached'
|
package/src/shared/framing.ts
CHANGED
|
@@ -33,11 +33,19 @@ export function framingOf(type: string | null | undefined): Framed | null {
|
|
|
33
33
|
export function carriesBytes(type: string | null | undefined): boolean {
|
|
34
34
|
if (type === null || type === undefined || type === '') return false
|
|
35
35
|
if (framingOf(type) !== null) return false
|
|
36
|
-
const
|
|
37
|
-
const essence = semicolon === -1 ? type : type.slice(0, semicolon)
|
|
36
|
+
const essence = essenceOf(type)
|
|
38
37
|
return essence !== 'application/json' && !essence.endsWith('+json')
|
|
39
38
|
}
|
|
40
39
|
|
|
40
|
+
/** A content type less its parameters, folded to lower case, as a type is compared; `''` for none. */
|
|
41
|
+
export function essenceOf(type: string | null | undefined): string {
|
|
42
|
+
if (type === null || type === undefined) return ''
|
|
43
|
+
const semicolon = type.indexOf(';')
|
|
44
|
+
return (semicolon === -1 ? type : type.slice(0, semicolon))
|
|
45
|
+
.trim()
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
}
|
|
48
|
+
|
|
41
49
|
/** One frame: an sse event's name — `message` where it named none — `id` and `data`, or a jsonl line. */
|
|
42
50
|
export type Frame = { name: string; id: string | undefined; data: string }
|
|
43
51
|
|