toolnexus 0.26.0 → 0.28.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 +6 -2
- package/dist/acp.d.ts +16 -2
- package/dist/acp.js +74 -21
- package/dist/agents/home.d.ts +85 -4
- package/dist/agents/home.js +347 -52
- package/dist/classifier.d.ts +21 -3
- package/dist/classifier.js +175 -9
- package/dist/client.d.ts +109 -0
- package/dist/client.js +380 -75
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +29 -7
- package/dist/mcp2026/source.d.ts +5 -0
- package/dist/mcp2026/source.js +11 -2
- package/dist/mcp2026/stdio.d.ts +43 -0
- package/dist/mcp2026/stdio.js +144 -20
- package/dist/mcphandler.js +2 -4
- package/dist/mcpserve.js +2 -1
- package/dist/nodefetch.d.ts +1 -0
- package/dist/nodefetch.js +156 -0
- package/dist/serve.js +5 -1
- package/dist/skill.d.ts +14 -0
- package/dist/skill.js +33 -2
- package/dist/transcript_search.d.ts +27 -0
- package/dist/transcript_search.js +165 -0
- package/package.json +2 -2
- package/src/acp.ts +101 -22
- package/src/agents/home.ts +381 -53
- package/src/classifier.ts +168 -12
- package/src/client.ts +368 -57
- package/src/index.ts +1 -0
- package/src/mcp.ts +36 -7
- package/src/mcp2026/source.ts +14 -4
- package/src/mcp2026/stdio.ts +148 -23
- package/src/mcphandler.ts +2 -3
- package/src/mcpserve.ts +2 -1
- package/src/nodefetch.ts +143 -0
- package/src/serve.ts +6 -2
- package/src/skill.ts +51 -2
- package/src/transcript_search.ts +172 -0
package/src/classifier.ts
CHANGED
|
@@ -34,7 +34,7 @@ export const DEFAULT_BATCH_CONCURRENCY = 16
|
|
|
34
34
|
* `typesafe/jev-1.14`, and mixing the two is issue #91. `custom` is a self-hosted System One
|
|
35
35
|
* endpoint and requires `baseUrl` + `supports` (SPEC §8B "Backends, images, cache and validation").
|
|
36
36
|
*/
|
|
37
|
-
export type ClassifierBackend = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "custom"
|
|
37
|
+
export type ClassifierBackend = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "openai" | "custom"
|
|
38
38
|
|
|
39
39
|
/** What a backend+model can take. DATA: a fail-fast for what the table says, never a claim about
|
|
40
40
|
* the vendor. Override per classifier with `ClassifierOptions.supports`. */
|
|
@@ -48,8 +48,9 @@ export interface ClassifierBackendPreset {
|
|
|
48
48
|
baseUrl: string
|
|
49
49
|
model: string
|
|
50
50
|
apiKeyEnv: string
|
|
51
|
-
/** `systemone`: POST {baseUrl}/systemone. `cloudflare`: POST {baseUrl}, `/ai/run` envelope.
|
|
52
|
-
|
|
51
|
+
/** `systemone`: POST {baseUrl}/systemone. `cloudflare`: POST {baseUrl}, `/ai/run` envelope.
|
|
52
|
+
* `decisions`: POST {baseUrl}/decisions, OpenAI's Decisions API (#157). */
|
|
53
|
+
wire: "systemone" | "cloudflare" | "decisions"
|
|
53
54
|
/** Send `TypeSafe-Danger-Zone-Images: true` when a request carries images. */
|
|
54
55
|
imagesHeader: boolean
|
|
55
56
|
/** The default support for a model on this backend; absent on `custom`. */
|
|
@@ -101,15 +102,46 @@ export const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, Readonly<Cl
|
|
|
101
102
|
imagesHeader: true,
|
|
102
103
|
supports: () => ({ images: false }),
|
|
103
104
|
},
|
|
105
|
+
openai: {
|
|
106
|
+
baseUrl: "https://api.openai.com/v1",
|
|
107
|
+
model: "gpt-6-luna",
|
|
108
|
+
apiKeyEnv: "OPENAI_API_KEY",
|
|
109
|
+
wire: "decisions",
|
|
110
|
+
imagesHeader: false,
|
|
111
|
+
supports: () => ({ images: true }),
|
|
112
|
+
},
|
|
104
113
|
custom: {
|
|
105
114
|
baseUrl: "",
|
|
106
115
|
model: DEFAULT_CLASSIFIER_MODEL,
|
|
107
|
-
apiKeyEnv:
|
|
116
|
+
apiKeyEnv: "",
|
|
108
117
|
wire: "systemone",
|
|
109
118
|
imagesHeader: true,
|
|
110
119
|
},
|
|
111
120
|
})
|
|
112
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The env var whose key may be sent to `baseUrl` (already `${NAME}`-expanded): the backend's own
|
|
124
|
+
* variable, and only when `baseUrl` is that backend's own host over https (OpenRouter also its
|
|
125
|
+
* subdomains). `""` when none may — `custom` has no preset key at all. A key is never sent to a
|
|
126
|
+
* host the caller did not name (#155, SPEC §8B).
|
|
127
|
+
*/
|
|
128
|
+
export function classifierKeyEnv(backend: ClassifierBackend, baseUrl: string): string {
|
|
129
|
+
const p = Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, backend) ? CLASSIFIER_BACKENDS[backend] : undefined
|
|
130
|
+
if (!p || !p.apiKeyEnv || !p.baseUrl) return ""
|
|
131
|
+
const hostOf = (u: string): string => {
|
|
132
|
+
try {
|
|
133
|
+
const url = new URL(u)
|
|
134
|
+
return url.protocol === "https:" ? url.hostname.toLowerCase() : ""
|
|
135
|
+
} catch {
|
|
136
|
+
return ""
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const h = hostOf(baseUrl)
|
|
140
|
+
const own = hostOf(p.baseUrl)
|
|
141
|
+
if (!h || !own) return ""
|
|
142
|
+
return h === own || (backend === "openrouter" && h.endsWith(".openrouter.ai")) ? p.apiKeyEnv : ""
|
|
143
|
+
}
|
|
144
|
+
|
|
113
145
|
/**
|
|
114
146
|
* Raised BEFORE any network call when the backend table says the backend cannot take what the
|
|
115
147
|
* request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
|
|
@@ -650,6 +682,9 @@ export class Classifier {
|
|
|
650
682
|
private readonly baseUrl: string
|
|
651
683
|
private readonly model: string
|
|
652
684
|
private readonly apiKeyEnv: string
|
|
685
|
+
/** The caller NAMED apiKeyEnv: sent wherever baseUrl points. Otherwise the preset's own key,
|
|
686
|
+
* and only to its own host (classifierKeyEnv, #155). */
|
|
687
|
+
private readonly explicitKeyEnv: boolean
|
|
653
688
|
private readonly timeoutMs: number
|
|
654
689
|
private readonly backend: ClassifierBackend
|
|
655
690
|
private readonly preset: Readonly<ClassifierBackendPreset>
|
|
@@ -664,7 +699,7 @@ export class Classifier {
|
|
|
664
699
|
this.backend = opts.backend ?? "typesafe"
|
|
665
700
|
if (!Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, this.backend)) {
|
|
666
701
|
throw new Error(
|
|
667
|
-
`classifier: unknown backend "${this.backend}" (want "typesafe", "openrouter", "cloudflare", "vercel" or "custom")`,
|
|
702
|
+
`classifier: unknown backend "${this.backend}" (want "typesafe", "openrouter", "cloudflare", "vercel", "openai" or "custom")`,
|
|
668
703
|
)
|
|
669
704
|
}
|
|
670
705
|
const preset = CLASSIFIER_BACKENDS[this.backend]
|
|
@@ -675,6 +710,7 @@ export class Classifier {
|
|
|
675
710
|
this.baseUrl = opts.baseUrl || preset.baseUrl
|
|
676
711
|
this.model = opts.model ?? preset.model
|
|
677
712
|
this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv
|
|
713
|
+
this.explicitKeyEnv = opts.apiKeyEnv !== undefined
|
|
678
714
|
const mismatch = backendMismatch(this.baseUrl, this.model)
|
|
679
715
|
if (mismatch) throw new Error(mismatch)
|
|
680
716
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS
|
|
@@ -893,7 +929,9 @@ export class Classifier {
|
|
|
893
929
|
const qs: Record<string, unknown> = {}
|
|
894
930
|
for (const [key, q] of Object.entries(questions)) qs[key] = questionWire(q)
|
|
895
931
|
let body: Record<string, any>
|
|
896
|
-
if (this.preset.wire === "
|
|
932
|
+
if (this.preset.wire === "decisions") {
|
|
933
|
+
body = { model: this.model, input: decisionsInput(p), questions: decisionsQuestions(this.backend, this.model, questions) }
|
|
934
|
+
} else if (this.preset.wire === "cloudflare") {
|
|
897
935
|
const input: Record<string, unknown> = { questions: qs, state: p.state }
|
|
898
936
|
if (p.images.length > 0) input.images = p.images.map((im) => `data:image/${im.format};base64,${im.data}`)
|
|
899
937
|
body = { model: this.model, input }
|
|
@@ -968,6 +1006,9 @@ export class Classifier {
|
|
|
968
1006
|
if (this.preset.wire === "cloudflare") {
|
|
969
1007
|
parsed = unwrapCloudflare(parsed)
|
|
970
1008
|
text = canonicalJson(parsed)
|
|
1009
|
+
} else if (this.preset.wire === "decisions") {
|
|
1010
|
+
text = decisionsReply(parsed, questions)
|
|
1011
|
+
parsed = JSON.parse(text)
|
|
971
1012
|
}
|
|
972
1013
|
const d = decodeDecision(parsed, this.model)
|
|
973
1014
|
const verr = validateDecision(questions, d)
|
|
@@ -993,7 +1034,8 @@ export class Classifier {
|
|
|
993
1034
|
// `endpoint` is what errors name: the CONFIGURED base, ${ENV} unexpanded, so an account id in
|
|
994
1035
|
// a templated URL never reaches an error. `url` is what is dialled, expanded at call time.
|
|
995
1036
|
let endpoint = this.baseUrl.replace(/\/+$/, "")
|
|
996
|
-
if (this.preset.wire
|
|
1037
|
+
if (this.preset.wire === "decisions") endpoint += "/decisions"
|
|
1038
|
+
else if (this.preset.wire !== "cloudflare") endpoint += "/systemone"
|
|
997
1039
|
const url = endpoint.includes("${") ? expandEnv(endpoint) : endpoint
|
|
998
1040
|
const retries = this.opts.retries ?? 2
|
|
999
1041
|
const base = this.opts.retryBaseMs ?? 500
|
|
@@ -1019,7 +1061,7 @@ export class Classifier {
|
|
|
1019
1061
|
try {
|
|
1020
1062
|
res = await doFetch(url, {
|
|
1021
1063
|
method: "POST",
|
|
1022
|
-
headers: this.requestHeaders(extraHeaders),
|
|
1064
|
+
headers: this.requestHeaders(extraHeaders, url),
|
|
1023
1065
|
body: payload,
|
|
1024
1066
|
signal: timer.signal,
|
|
1025
1067
|
})
|
|
@@ -1049,9 +1091,10 @@ export class Classifier {
|
|
|
1049
1091
|
}
|
|
1050
1092
|
|
|
1051
1093
|
/** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
|
|
1052
|
-
private requestHeaders(extra: Record<string, string
|
|
1094
|
+
private requestHeaders(extra: Record<string, string>, url: string): Record<string, string> {
|
|
1053
1095
|
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
|
1054
|
-
const
|
|
1096
|
+
const keyEnv = this.explicitKeyEnv ? this.apiKeyEnv : classifierKeyEnv(this.backend, url)
|
|
1097
|
+
const key = keyEnv ? process.env[keyEnv] : undefined
|
|
1055
1098
|
if (key) headers["Authorization"] = `Bearer ${key}`
|
|
1056
1099
|
// The image opt-in header goes BEFORE host headers, which win.
|
|
1057
1100
|
for (const [k, v] of Object.entries(extra)) headers[k] = v
|
|
@@ -1341,10 +1384,27 @@ function imageSize(format: string, b: Uint8Array): [number, number] | undefined
|
|
|
1341
1384
|
return undefined
|
|
1342
1385
|
}
|
|
1343
1386
|
|
|
1344
|
-
/**
|
|
1387
|
+
/**
|
|
1388
|
+
* `: <code> <message>` from errors[0] of a failed envelope (SPEC §8B), through the same redaction
|
|
1389
|
+
* and cap as cause(). Cloudflare reports a missing balance, a bad model id and a malformed
|
|
1390
|
+
* request alike as HTTP 200 + success:false, so this is the only thing that tells them apart.
|
|
1391
|
+
* `""` when there is nothing to show.
|
|
1392
|
+
*/
|
|
1393
|
+
function cloudflareCause(env: unknown): string {
|
|
1394
|
+
const errs = env !== null && typeof env === "object" ? (env as any).errors : undefined
|
|
1395
|
+
const e0 = Array.isArray(errs) ? errs[0] : undefined
|
|
1396
|
+
if (e0 === null || typeof e0 !== "object") return ""
|
|
1397
|
+
const parts: string[] = []
|
|
1398
|
+
if ((typeof e0.code === "number" || typeof e0.code === "string") && String(e0.code) !== "") parts.push(String(e0.code))
|
|
1399
|
+
if (typeof e0.message === "string" && e0.message !== "") parts.push(e0.message)
|
|
1400
|
+
const s = capErrorBody(redactErrorBody(parts.join(" ")))
|
|
1401
|
+
return s ? ": " + s : ""
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/** The System One reply inside a Cloudflare `/ai/run` envelope. Only errors[0] is ever echoed. */
|
|
1345
1405
|
function unwrapCloudflare(env: unknown): unknown {
|
|
1346
1406
|
if (env === null || typeof env !== "object" || Array.isArray(env) || (env as any).success !== true) {
|
|
1347
|
-
throw new Error("classifier: cloudflare: envelope not successful")
|
|
1407
|
+
throw new Error("classifier: cloudflare: envelope not successful" + cloudflareCause(env))
|
|
1348
1408
|
}
|
|
1349
1409
|
let result = (env as any).result
|
|
1350
1410
|
if (result !== null && typeof result === "object" && !Array.isArray(result) && typeof result.state === "string") {
|
|
@@ -1514,3 +1574,99 @@ export function chunkQuestions<Q extends AnyQuestion>(questions: Record<string,
|
|
|
1514
1574
|
}
|
|
1515
1575
|
return out
|
|
1516
1576
|
}
|
|
1577
|
+
|
|
1578
|
+
// ---------------------------------------------------------------- the Decisions wire (#157)
|
|
1579
|
+
|
|
1580
|
+
/** The Decisions API's per-request question cap. */
|
|
1581
|
+
export const MAX_DECISIONS_QUESTIONS = 400
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Maps the question set to the Decisions API list, in ASCII key order. Caller keys are NOT sent
|
|
1585
|
+
* (no `name`): §8B keys are addressing, not content, and answers come back in request order.
|
|
1586
|
+
* Every check fires before any request.
|
|
1587
|
+
*/
|
|
1588
|
+
function decisionsQuestions(backend: string, model: string, questions: Record<string, AnyQuestion>): unknown[] {
|
|
1589
|
+
const keys = Object.keys(questions).sort()
|
|
1590
|
+
if (keys.length > MAX_DECISIONS_QUESTIONS) {
|
|
1591
|
+
throw new Error(`classifier: the decisions wire takes at most ${MAX_DECISIONS_QUESTIONS} questions, got ${keys.length}`)
|
|
1592
|
+
}
|
|
1593
|
+
return keys.map((k) => {
|
|
1594
|
+
const q = questions[k]
|
|
1595
|
+
if (isRawQuestion(q)) throw new ClassifierUnsupported(backend, model, `raw question type "${q.type}"`)
|
|
1596
|
+
const m = q as Question
|
|
1597
|
+
switch (m.type) {
|
|
1598
|
+
case "noul": {
|
|
1599
|
+
let instructions = m.instructions
|
|
1600
|
+
if (m.criteria !== undefined) instructions += `\n\nTrue means: ${m.criteria.true}\nFalse means: ${m.criteria.false}`
|
|
1601
|
+
return { type: "predicate", instructions }
|
|
1602
|
+
}
|
|
1603
|
+
case "choice": {
|
|
1604
|
+
const ids = Object.keys(m.criteria ?? {}).sort()
|
|
1605
|
+
if (ids.length < 2) {
|
|
1606
|
+
throw new Error(`classifier: question "${k}": the decisions wire needs 2..${MAX_CHOICE_OPTIONS} choice options, got ${ids.length}`)
|
|
1607
|
+
}
|
|
1608
|
+
const choices = ids.map((id) => (m.criteria[id] === "" ? { value: id } : { value: id, description: m.criteria[id] }))
|
|
1609
|
+
return { type: "choice", instructions: m.instructions, choices }
|
|
1610
|
+
}
|
|
1611
|
+
case "score":
|
|
1612
|
+
return { type: "score", instructions: m.instructions, levels: m.criteria.map((label) => ({ label })) }
|
|
1613
|
+
}
|
|
1614
|
+
})
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/** The state as sent: a string verbatim, else canonical JSON text; with images, one user message. */
|
|
1618
|
+
function decisionsInput(p: PreparedRequest): unknown {
|
|
1619
|
+
const text = typeof p.state === "string" ? p.state : canonicalJson(p.state)
|
|
1620
|
+
if (p.images.length === 0) return text
|
|
1621
|
+
const content: unknown[] = [{ type: "input_text", text }]
|
|
1622
|
+
for (const im of p.images) content.push({ type: "input_image", image_url: `data:image/${im.format};base64,${im.data}` })
|
|
1623
|
+
return [{ role: "user", content }]
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/** A choice value or level index as the §8B key: string as written, boolean "true"/"false", number shortest. */
|
|
1627
|
+
function decisionsValue(v: unknown): string {
|
|
1628
|
+
return typeof v === "string" ? v : String(v)
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* Reads a Decisions API reply back into a System One-shaped body (canonical JSON), so decode,
|
|
1633
|
+
* validation and the cache are the one path. `calibrated` is false. Exported for conformance tests.
|
|
1634
|
+
*/
|
|
1635
|
+
export function decisionsReply(reply: unknown, questions: Record<string, AnyQuestion>): string {
|
|
1636
|
+
const r = reply as any
|
|
1637
|
+
if (r === null || typeof r !== "object" || Array.isArray(r) || !Array.isArray(r.answers)) {
|
|
1638
|
+
throw new Error("classifier: decisions: malformed reply")
|
|
1639
|
+
}
|
|
1640
|
+
const keys = Object.keys(questions).sort()
|
|
1641
|
+
if (r.answers.length !== keys.length) {
|
|
1642
|
+
throw new Error(`classifier: decisions: ${r.answers.length} answers for ${keys.length} questions`)
|
|
1643
|
+
}
|
|
1644
|
+
const num = (v: unknown): number => (typeof v === "number" ? v : 0)
|
|
1645
|
+
const want: Record<string, string> = { noul: "predicate", choice: "choice", score: "score" }
|
|
1646
|
+
const answers: Record<string, unknown> = {}
|
|
1647
|
+
keys.forEach((k, i) => {
|
|
1648
|
+
const a = r.answers[i]
|
|
1649
|
+
if (a === null || typeof a !== "object" || Array.isArray(a)) throw new Error("classifier: decisions: malformed reply")
|
|
1650
|
+
if (a.type === "refusal") throw new Error(`classifier: decisions: question "${k}" was refused`)
|
|
1651
|
+
const q = questions[k]
|
|
1652
|
+
const w = want[q.type] ?? ""
|
|
1653
|
+
if (a.type !== w) throw new Error(`classifier: decisions: answer "${k}" is a ${a.type} answer, not ${w}`)
|
|
1654
|
+
const probs: Record<string, number> = {}
|
|
1655
|
+
for (const p of Array.isArray(a.probabilities) ? a.probabilities : []) probs[decisionsValue(p?.value)] = num(p?.probability)
|
|
1656
|
+
if (q.type === "noul") answers[k] = { type: "noul", noul: num(a.probability) }
|
|
1657
|
+
else if (q.type === "choice") {
|
|
1658
|
+
answers[k] = { type: "choice", choice: decisionsValue(a.choice), probabilities: probs, confidence: num(a.confidence) }
|
|
1659
|
+
} else {
|
|
1660
|
+
const legend: Record<string, string> = {}
|
|
1661
|
+
;(q as ScoreQuestion).criteria.forEach((l, j) => (legend[String(j)] = l))
|
|
1662
|
+
answers[k] = { type: "score", score: num(a.score), legend, probabilities: probs, confidence: num(a.confidence) }
|
|
1663
|
+
}
|
|
1664
|
+
})
|
|
1665
|
+
const u = r.usage ?? {}
|
|
1666
|
+
return canonicalJson({
|
|
1667
|
+
answers,
|
|
1668
|
+
calibrated: false,
|
|
1669
|
+
model: typeof r.model === "string" ? r.model : "",
|
|
1670
|
+
usage: { input_tokens: num(u.input_tokens), output_tokens: num(u.output_tokens) },
|
|
1671
|
+
})
|
|
1672
|
+
}
|