toolnexus 0.16.0 → 0.17.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/README.md +46 -0
- package/dist/agents/agent.d.ts +11 -1
- package/dist/agents/agent.js +0 -9
- package/dist/agents/compaction.d.ts +0 -25
- package/dist/agents/compaction.js +41 -2
- package/dist/agents/loop.d.ts +3 -2
- package/dist/agents/loop.js +0 -14
- package/dist/agents/runtime.d.ts +4 -3
- package/dist/agents/runtime.js +8 -17
- package/dist/builtin.js +24 -3
- package/dist/client.d.ts +14 -4
- package/dist/client.js +35 -8
- package/dist/content.d.ts +158 -0
- package/dist/content.js +379 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp.d.ts +9 -0
- package/dist/mcp.js +59 -4
- package/dist/mcpserve.js +27 -1
- package/dist/translate.js +30 -2
- package/dist/types.d.ts +8 -0
- package/dist/wire.d.ts +40 -0
- package/dist/wire.js +104 -0
- package/package.json +2 -2
- package/src/agents/agent.ts +2 -1
- package/src/agents/compaction.ts +39 -1
- package/src/agents/loop.ts +5 -4
- package/src/agents/runtime.ts +13 -5
- package/src/builtin.ts +23 -3
- package/src/client.ts +53 -16
- package/src/content.ts +448 -0
- package/src/index.ts +2 -0
- package/src/mcp.ts +55 -4
- package/src/mcpserve.ts +26 -3
- package/src/translate.ts +25 -1
- package/src/types.ts +9 -0
- package/src/wire.ts +131 -0
package/src/mcp.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
|
11
11
|
import type { Tool as McpToolDef, ElicitRequest, ElicitResult } from "@modelcontextprotocol/sdk/types.js"
|
|
12
12
|
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
|
13
13
|
import { type JSONSchema, type Tool, type ToolContext, type ToolResult, type McpStatus, type Request, type Answer, sanitize } from "./types.js"
|
|
14
|
+
import { type ContentPart, summarizePart } from "./content.js"
|
|
14
15
|
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
16
17
|
// MCP elicitation bridge (§10). A server can ask US for input mid-`tools/call`
|
|
@@ -108,6 +109,43 @@ export function expandEnvHeaders(headers?: Record<string, string>): Record<strin
|
|
|
108
109
|
return out
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* §0.4 / §2. Map every non-text `content[]` entry to a `ContentPart`, so nothing an MCP
|
|
114
|
+
* server returns is dropped silently: image ⇒ image, audio ⇒ audio, `resource_link` ⇒
|
|
115
|
+
* `file{url}`, an embedded `resource` carrying a blob ⇒ `file{data}`. An embedded resource
|
|
116
|
+
* carrying *text* is not a part — it belongs in `output`, and {@link joinTextContent}
|
|
117
|
+
* takes it there.
|
|
118
|
+
*/
|
|
119
|
+
export function mcpContentToParts(content: unknown): ContentPart[] | undefined {
|
|
120
|
+
if (!Array.isArray(content)) return undefined
|
|
121
|
+
const parts: ContentPart[] = []
|
|
122
|
+
for (const item of content) {
|
|
123
|
+
const it = item as any
|
|
124
|
+
if (!it || typeof it !== "object") continue
|
|
125
|
+
// `data` / `blob` are base64 STRINGS in the SDK, never bytes — passed through verbatim
|
|
126
|
+
// so no encoder of ours can drift them away from what the server sent.
|
|
127
|
+
if (it.type === "image" && typeof it.data === "string") {
|
|
128
|
+
parts.push({ type: "image", mimeType: String(it.mimeType ?? "application/octet-stream"), data: it.data })
|
|
129
|
+
} else if (it.type === "audio" && typeof it.data === "string") {
|
|
130
|
+
parts.push({ type: "audio", mimeType: String(it.mimeType ?? "application/octet-stream"), data: it.data })
|
|
131
|
+
} else if (it.type === "resource_link" && it.uri) {
|
|
132
|
+
const part: ContentPart = { type: "file", mimeType: String(it.mimeType ?? "application/octet-stream"), url: String(it.uri) }
|
|
133
|
+
if (it.name) (part as any).name = String(it.name)
|
|
134
|
+
parts.push(part)
|
|
135
|
+
} else if (it.type === "resource" && typeof it.resource?.blob === "string") {
|
|
136
|
+
const part: ContentPart = { type: "file", mimeType: String(it.resource.mimeType ?? "application/octet-stream"), data: it.resource.blob }
|
|
137
|
+
if (it.resource.uri) (part as any).name = String(it.resource.uri)
|
|
138
|
+
parts.push(part)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return parts.length ? parts : undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** An image-only result must not answer the model with "". Name what came back instead. */
|
|
145
|
+
function describeMcpParts(parts: ContentPart[] | undefined): string {
|
|
146
|
+
return parts ? parts.map(summarizePart).join("\n") : ""
|
|
147
|
+
}
|
|
148
|
+
|
|
111
149
|
function formatToolErrorContent(content: unknown): string {
|
|
112
150
|
if (!Array.isArray(content)) return "MCP tool returned an error"
|
|
113
151
|
return (
|
|
@@ -127,10 +165,17 @@ function isTextContent(value: unknown): value is { type: "text"; text: string }
|
|
|
127
165
|
)
|
|
128
166
|
}
|
|
129
167
|
|
|
168
|
+
/** An embedded resource carrying text is text: it joins `output` in place, keeping the
|
|
169
|
+
* server's ordering, rather than being discarded with the other non-text entries. */
|
|
170
|
+
function isResourceText(value: unknown): value is { resource: { text: string } } {
|
|
171
|
+
const v = value as any
|
|
172
|
+
return v?.type === "resource" && typeof v?.resource?.text === "string"
|
|
173
|
+
}
|
|
174
|
+
|
|
130
175
|
function joinTextContent(content: unknown): string {
|
|
131
176
|
if (!Array.isArray(content)) return ""
|
|
132
177
|
return content
|
|
133
|
-
.flatMap((item) => (isTextContent(item) ? [item.text] : []))
|
|
178
|
+
.flatMap((item) => (isTextContent(item) ? [item.text] : isResourceText(item) ? [item.resource.text] : []))
|
|
134
179
|
.join("\n")
|
|
135
180
|
}
|
|
136
181
|
|
|
@@ -202,13 +247,19 @@ function convertTool(server: string, def: McpToolDef, client: Client, timeout: n
|
|
|
202
247
|
undefined,
|
|
203
248
|
{ resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout },
|
|
204
249
|
)
|
|
250
|
+
// Parts are collected BEFORE any branch returns: the isError and structuredContent
|
|
251
|
+
// short-circuits both read `content[]` on the way past, so neither reintroduces the
|
|
252
|
+
// silent drop this mapping exists to remove (§2).
|
|
253
|
+
const parts = mcpContentToParts(result.content)
|
|
254
|
+
const withParts = (r: ToolResult): ToolResult => (parts ? { ...r, parts } : r)
|
|
205
255
|
if (result.isError) {
|
|
206
|
-
return { output: formatToolErrorContent(result.content), isError: true, metadata: { server } }
|
|
256
|
+
return withParts({ output: formatToolErrorContent(result.content), isError: true, metadata: { server } })
|
|
207
257
|
}
|
|
208
258
|
if (result.structuredContent !== undefined && result.structuredContent !== null) {
|
|
209
|
-
return { output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } }
|
|
259
|
+
return withParts({ output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } })
|
|
210
260
|
}
|
|
211
|
-
|
|
261
|
+
const text = joinTextContent(result.content)
|
|
262
|
+
return withParts({ output: text || describeMcpParts(parts), isError: false, metadata: { server } })
|
|
212
263
|
} catch (e) {
|
|
213
264
|
return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } }
|
|
214
265
|
}
|
package/src/mcpserve.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
|
16
16
|
import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"
|
|
17
|
-
import type { Tool, ToolSource } from "./types.js"
|
|
17
|
+
import type { Tool, ToolSource, ToolResult } from "./types.js"
|
|
18
|
+
import type { ContentPart } from "./content.js"
|
|
18
19
|
|
|
19
20
|
const DEFAULT_NAME = "toolnexus"
|
|
20
21
|
const DEFAULT_VERSION = "0.1.0"
|
|
@@ -75,7 +76,7 @@ export function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnC
|
|
|
75
76
|
// The call is a single synchronous tool invocation honoring the MCP
|
|
76
77
|
// request's cancellation. An execute() throw becomes an isError result —
|
|
77
78
|
// never a crash.
|
|
78
|
-
let result
|
|
79
|
+
let result: ToolResult
|
|
79
80
|
try {
|
|
80
81
|
result = await tool.execute(req.params.arguments ?? {}, { signal: extra.signal })
|
|
81
82
|
} catch (e) {
|
|
@@ -88,8 +89,30 @@ export function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnC
|
|
|
88
89
|
// Host callback errors are isolated.
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
|
-
return { content: [{ type: "text", text: result.output }], isError: result.isError }
|
|
92
|
+
return { content: [{ type: "text", text: result.output }, ...partBlocks(result.parts)], isError: result.isError }
|
|
92
93
|
})
|
|
93
94
|
|
|
94
95
|
return server
|
|
95
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* §7C. A served tool's `ToolResult.parts` become MCP content blocks appended after the
|
|
100
|
+
* text part, in order — image ⇒ image content, audio ⇒ audio content, file ⇒ an embedded
|
|
101
|
+
* resource (a url-backed file keeps its uri, a byte-backed one carries the blob).
|
|
102
|
+
*/
|
|
103
|
+
function partBlocks(parts: ContentPart[] | undefined): any[] {
|
|
104
|
+
if (!parts?.length) return []
|
|
105
|
+
const out: any[] = []
|
|
106
|
+
for (const p of parts) {
|
|
107
|
+
if (p.type === "text") out.push({ type: "text", text: p.text })
|
|
108
|
+
else if (p.type === "image" && p.data) out.push({ type: "image", data: p.data, mimeType: p.mimeType })
|
|
109
|
+
else if (p.type === "audio" && p.data) out.push({ type: "audio", data: p.data, mimeType: p.mimeType })
|
|
110
|
+
else {
|
|
111
|
+
const uri = p.url ?? `file:///${(p as any).name ?? "resource"}`
|
|
112
|
+
const resource: any = { uri, mimeType: p.mimeType }
|
|
113
|
+
if (p.data) resource.blob = p.data
|
|
114
|
+
out.push({ type: "resource", resource })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
package/src/translate.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { Toolkit } from "./toolkit.js"
|
|
17
|
+
import { encodeParts, inboundPart, type ContentPart } from "./content.js"
|
|
17
18
|
import type { Usage } from "./client.js"
|
|
18
19
|
|
|
19
20
|
/** An OpenAI-shaped chat request, handed over verbatim (§11). */
|
|
@@ -187,9 +188,14 @@ export function openAIMessagesToAnthropic(messages: any[]): { messages: any[]; s
|
|
|
187
188
|
}
|
|
188
189
|
default: {
|
|
189
190
|
flush()
|
|
191
|
+
// A `content` array has its text parts concatenated and its NON-text parts translated
|
|
192
|
+
// into Anthropic's block shape (§11). Six ports used to pass a text-empty array through
|
|
193
|
+
// raw and undocumented; one specified mapping replaces that, so nothing is flattened
|
|
194
|
+
// away and nothing is dropped.
|
|
195
|
+
const blocks = contentPartBlocks(m.content)
|
|
196
|
+
if (blocks) { out.push({ role: "user", content: blocks }); break }
|
|
190
197
|
const s = contentText(m.content)
|
|
191
198
|
if (s) out.push({ role: "user", content: s })
|
|
192
|
-
else if (Array.isArray(m.content) && m.content.length) out.push({ role: "user", content: m.content })
|
|
193
199
|
}
|
|
194
200
|
}
|
|
195
201
|
}
|
|
@@ -197,6 +203,24 @@ export function openAIMessagesToAnthropic(messages: any[]): { messages: any[]; s
|
|
|
197
203
|
return { messages: out, system: systemParts.join("\n\n") }
|
|
198
204
|
}
|
|
199
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Translate an OpenAI-shaped `content` array into Anthropic blocks — but ONLY when it
|
|
208
|
+
* actually carries something non-text; an all-text array still concatenates, so the
|
|
209
|
+
* common case is byte-identical. Both spellings are accepted: a literal `ContentPart`
|
|
210
|
+
* and the provider-native block that same part encodes to.
|
|
211
|
+
*/
|
|
212
|
+
function contentPartBlocks(content: unknown): any[] | undefined {
|
|
213
|
+
if (!Array.isArray(content) || content.length === 0) return undefined
|
|
214
|
+
const parts: ContentPart[] = []
|
|
215
|
+
for (const raw of content) {
|
|
216
|
+
const p = inboundPart(raw)
|
|
217
|
+
if (!p) return undefined // an array we do not understand is left to contentText, as before
|
|
218
|
+
parts.push(p)
|
|
219
|
+
}
|
|
220
|
+
if (parts.every((p) => p.type === "text")) return undefined
|
|
221
|
+
return encodeParts(parts, { style: "anthropic", provenance: "attached" })
|
|
222
|
+
}
|
|
223
|
+
|
|
200
224
|
/**
|
|
201
225
|
* Converts an OpenAI `tools` array into Anthropic tool declarations. Entries that are
|
|
202
226
|
* already provider-native pass through; anything unrecognized is skipped.
|
package/src/types.ts
CHANGED
|
@@ -10,11 +10,20 @@ export type JSONSchema = {
|
|
|
10
10
|
[k: string]: unknown
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
import type { ContentPart } from "./content.js"
|
|
14
|
+
|
|
13
15
|
export type ToolSource = "mcp" | "skill" | "builtin" | "native" | "http" | "a2a" | "custom"
|
|
14
16
|
|
|
15
17
|
export interface ToolResult {
|
|
16
18
|
output: string
|
|
17
19
|
isError: boolean
|
|
20
|
+
/**
|
|
21
|
+
* §1B non-text output. `output` stays required and remains what the transcript,
|
|
22
|
+
* compaction, token estimation and any text-only provider see; `parts` is the extra
|
|
23
|
+
* channel a screenshot / PDF / audio clip travels on. Absent ⇒ byte-identical to a
|
|
24
|
+
* pre-0.17 result, which is why it is optional rather than a fourth required field.
|
|
25
|
+
*/
|
|
26
|
+
parts?: ContentPart[]
|
|
18
27
|
metadata?: Record<string, unknown>
|
|
19
28
|
}
|
|
20
29
|
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical transcript → provider wire messages (SPEC.md §8A).
|
|
3
|
+
*
|
|
4
|
+
* The loop's `messages` array is the **canonical transcript**: a user turn carrying parts
|
|
5
|
+
* holds `ContentPart[]`, and a tool turn carrying parts holds them under a `parts` key.
|
|
6
|
+
* Neither shape is a wire shape. This module is the single place that turns the canonical
|
|
7
|
+
* transcript into what each style actually accepts, so the four loop sites (run/stream ×
|
|
8
|
+
* openai/anthropic) only ever have to *record* parts, never encode them.
|
|
9
|
+
*
|
|
10
|
+
* It is also where the **tool-result relocation rule** lives. Anthropic defines blocks
|
|
11
|
+
* inside `tool_result.content`, so its parts are emitted natively, keyed to their
|
|
12
|
+
* `tool_use_id`. OpenAI's `tool` message rejects an image outright (a hard 400 — "Image URLs
|
|
13
|
+
* are only allowed for messages with role 'user'"), so there every non-text part from every
|
|
14
|
+
* tool result answering one assistant turn is relocated, in tool-call order, into ONE
|
|
15
|
+
* synthetic `user` message emitted immediately after the last tool message. That synthetic
|
|
16
|
+
* message is an adapter artifact: it is built here, on the way out, and never written back
|
|
17
|
+
* to the transcript, the `ConversationStore` or `translate` output — which is what keeps a
|
|
18
|
+
* mid-conversation provider switch free of OpenAI-shaped residue.
|
|
19
|
+
*/
|
|
20
|
+
import type { ContentPart, PartStyle, UnsupportedPartMode } from "./content.js"
|
|
21
|
+
import { encodeParts, isContentPart, validatePart } from "./content.js"
|
|
22
|
+
|
|
23
|
+
/** Shared shaping options for a wire build. */
|
|
24
|
+
export interface WireOptions {
|
|
25
|
+
onUnsupportedPart?: UnsupportedPartMode
|
|
26
|
+
maxPartBytes?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The text preceding each relocated part, so the model can attribute it to its call. */
|
|
30
|
+
export const relocationHeader = (name: string, id: string) => `Output of tool ${name} (${id}):`
|
|
31
|
+
|
|
32
|
+
/** Split parts into the text ones (which ride along in place) and the rest. */
|
|
33
|
+
function split(parts: readonly ContentPart[]): { texts: ContentPart[]; others: ContentPart[] } {
|
|
34
|
+
const texts: ContentPart[] = []
|
|
35
|
+
const others: ContentPart[] = []
|
|
36
|
+
for (const p of parts) (p.type === "text" ? texts : others).push(p)
|
|
37
|
+
return { texts, others }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function partsOf(m: any): ContentPart[] | undefined {
|
|
41
|
+
const p = m?.parts
|
|
42
|
+
return Array.isArray(p) && p.length ? (p as ContentPart[]) : undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when a message's `content` is a canonical `ContentPart[]` rather than a wire array. */
|
|
46
|
+
function isPartArray(content: unknown): content is ContentPart[] {
|
|
47
|
+
return Array.isArray(content) && content.length > 0 && content.every(isContentPart)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the OpenAI-style wire messages. Tool messages carry `output` plus text parts only;
|
|
52
|
+
* their non-text parts are collected and flushed as one synthetic user message the moment
|
|
53
|
+
* the run of tool messages ends.
|
|
54
|
+
*/
|
|
55
|
+
export function toOpenAIWire(messages: any[], opts: WireOptions = {}): any[] {
|
|
56
|
+
const out: any[] = []
|
|
57
|
+
let relocated: { name: string; id: string; parts: ContentPart[] }[] = []
|
|
58
|
+
|
|
59
|
+
const flush = () => {
|
|
60
|
+
if (!relocated.length) return
|
|
61
|
+
const blocks: any[] = []
|
|
62
|
+
for (const r of relocated) {
|
|
63
|
+
blocks.push({ type: "text", text: relocationHeader(r.name, r.id) })
|
|
64
|
+
blocks.push(...encodeParts(r.parts, { style: "openai", provenance: "derived", ...opts }))
|
|
65
|
+
}
|
|
66
|
+
relocated = []
|
|
67
|
+
out.push({ role: "user", content: blocks })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const m of messages ?? []) {
|
|
71
|
+
if (m?.role === "tool") {
|
|
72
|
+
const parts = partsOf(m)
|
|
73
|
+
const { parts: _drop, ...rest } = m ?? {}
|
|
74
|
+
if (!parts) {
|
|
75
|
+
out.push(m)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
const { texts, others } = split(parts)
|
|
79
|
+
const msg: any = { ...rest }
|
|
80
|
+
if (texts.length) {
|
|
81
|
+
msg.content = [{ type: "text", text: String(rest.content ?? "") }, ...encodeParts(texts, { style: "openai", provenance: "derived", ...opts })]
|
|
82
|
+
}
|
|
83
|
+
out.push(msg)
|
|
84
|
+
if (others.length) relocated.push({ name: String(rest.name ?? ""), id: String(rest.tool_call_id ?? ""), parts: others })
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
flush()
|
|
88
|
+
if (isPartArray(m?.content)) {
|
|
89
|
+
out.push({ ...m, content: encodeParts(m.content, { style: "openai", provenance: "attached", ...opts }) })
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
out.push(m)
|
|
93
|
+
}
|
|
94
|
+
flush()
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the Anthropic-style wire messages. Nothing is relocated: a `tool_result` block that
|
|
100
|
+
* carries parts gets them natively inside its own `content`, keyed to its `tool_use_id`.
|
|
101
|
+
*/
|
|
102
|
+
export function toAnthropicWire(messages: any[], opts: WireOptions = {}): any[] {
|
|
103
|
+
return (messages ?? []).map((m) => {
|
|
104
|
+
if (isPartArray(m?.content)) {
|
|
105
|
+
return { ...m, content: encodeParts(m.content, { style: "anthropic", provenance: "attached", ...opts }) }
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(m?.content)) return m
|
|
108
|
+
let touched = false
|
|
109
|
+
const content = m.content.map((block: any) => {
|
|
110
|
+
if (block?.type !== "tool_result") return block
|
|
111
|
+
const parts = partsOf(block)
|
|
112
|
+
if (!parts) return block
|
|
113
|
+
touched = true
|
|
114
|
+
const { parts: _drop, ...rest } = block
|
|
115
|
+
return {
|
|
116
|
+
...rest,
|
|
117
|
+
content: [
|
|
118
|
+
{ type: "text", text: String(rest.content ?? "") },
|
|
119
|
+
...encodeParts(parts, { style: "anthropic", provenance: "derived", ...opts }),
|
|
120
|
+
],
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
return touched ? { ...m, content } : m
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Validate every attached part before assembly, so `maxPartBytes` cannot be bypassed. */
|
|
128
|
+
export function checkPromptParts(prompt: unknown, opts: WireOptions): void {
|
|
129
|
+
if (!Array.isArray(prompt)) return
|
|
130
|
+
for (const p of prompt) validatePart(p as ContentPart, opts.maxPartBytes)
|
|
131
|
+
}
|