toolnexus 0.27.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 +6 -0
- package/dist/acp.js +38 -16
- package/dist/agents/home.d.ts +85 -4
- package/dist/agents/home.js +347 -52
- package/dist/classifier.d.ts +11 -3
- package/dist/classifier.js +120 -3
- 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 +13 -1
- package/dist/nodefetch.d.ts +1 -0
- package/dist/nodefetch.js +156 -0
- package/dist/transcript_search.d.ts +27 -0
- package/dist/transcript_search.js +165 -0
- package/package.json +2 -2
- package/src/acp.ts +42 -15
- package/src/agents/home.ts +381 -53
- package/src/classifier.ts +117 -6
- package/src/client.ts +368 -57
- package/src/index.ts +1 -0
- package/src/mcp.ts +18 -1
- package/src/nodefetch.ts +143 -0
- package/src/transcript_search.ts +172 -0
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/mcp.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
8
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9
|
+
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
|
|
9
10
|
import { AdoptedStdioTransport } from "./mcp2026/stdio.js";
|
|
10
11
|
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
11
12
|
import { sanitize } from "./types.js";
|
|
@@ -398,8 +399,19 @@ function resourceTools(servers) {
|
|
|
398
399
|
* exists. Separated from connect so the caller ALWAYS holds a closeable reference — even if the
|
|
399
400
|
* connect is later abandoned by a timeout/abort, `client.close()` still terminates any spawned
|
|
400
401
|
* stdio child (no orphaned processes on cancellation). */
|
|
402
|
+
/**
|
|
403
|
+
* The SDK's default validator, built on first use instead of per Client. Building an Ajv instance
|
|
404
|
+
* (meta-schemas + formats) costs ~2-3 ms on every connect, yet it is only consulted when a tool
|
|
405
|
+
* declares an `outputSchema`. Same validator, same results — just not paid for up front.
|
|
406
|
+
*/
|
|
407
|
+
class LazyAjvValidator {
|
|
408
|
+
inner;
|
|
409
|
+
getValidator(schema) {
|
|
410
|
+
return (this.inner ??= new AjvJsonSchemaValidator()).getValidator(schema);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
401
413
|
function newMcpClient(waitFor) {
|
|
402
|
-
const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {} });
|
|
414
|
+
const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: waitFor ? { elicitation: {} } : {}, jsonSchemaValidator: new LazyAjvValidator() });
|
|
403
415
|
if (waitFor) {
|
|
404
416
|
client.setRequestHandler(ElicitRequestSchema, async (request) => answerToElicitResult(await waitFor(elicitationToRequest(request.params))));
|
|
405
417
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const nodeFetch: typeof fetch;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default LLM transport: a `fetch`-shaped function over `node:http`/`node:https` with a shared
|
|
3
|
+
* keep-alive agent.
|
|
4
|
+
*
|
|
5
|
+
* Why: Node's built-in `fetch` (undici + web streams) costs ~1.5 ms per local round-trip, against
|
|
6
|
+
* ~0.3 ms for `node:http` on the same keep-alive socket. With two LLM calls per tool-calling turn
|
|
7
|
+
* that was most of the client's per-request overhead (benchmarks/run_toolnexus_js.mjs).
|
|
8
|
+
*
|
|
9
|
+
* Behavior is kept identical to `fetch` for what the client uses (status, headers incl.
|
|
10
|
+
* Retry-After, json/text, a streamed `body` for SSE, abort via `signal` rejecting with its reason).
|
|
11
|
+
* Anything this path does not cover is handed to `globalThis.fetch` unchanged: a proxy env
|
|
12
|
+
* (`HTTP(S)_PROXY`, `NODE_USE_ENV_PROXY`), a replaced global `fetch`, a non-http(s) URL, a body
|
|
13
|
+
* that is not a string/bytes, and redirects.
|
|
14
|
+
*/
|
|
15
|
+
import http from "node:http";
|
|
16
|
+
import https from "node:https";
|
|
17
|
+
import { Readable } from "node:stream";
|
|
18
|
+
import zlib from "node:zlib";
|
|
19
|
+
const nativeFetch = globalThis.fetch;
|
|
20
|
+
const httpAgent = new http.Agent({ keepAlive: true });
|
|
21
|
+
const httpsAgent = new https.Agent({ keepAlive: true });
|
|
22
|
+
function proxyEnv() {
|
|
23
|
+
const e = process.env;
|
|
24
|
+
return !!(e.HTTP_PROXY || e.HTTPS_PROXY || e.http_proxy || e.https_proxy || e.NODE_USE_ENV_PROXY);
|
|
25
|
+
}
|
|
26
|
+
function toHeaderRecord(h) {
|
|
27
|
+
const out = {};
|
|
28
|
+
if (!h)
|
|
29
|
+
return out;
|
|
30
|
+
if (h instanceof Headers || Array.isArray(h)) {
|
|
31
|
+
for (const [k, v] of h)
|
|
32
|
+
out[k] = v;
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
return { ...h };
|
|
36
|
+
}
|
|
37
|
+
// undici's global dispatcher slot. Node fills it lazily with a default Agent on the first fetch;
|
|
38
|
+
// anything else there (a ProxyAgent, a mock, a tuned Agent) means the host routed fetch on purpose.
|
|
39
|
+
const DISPATCHER = Symbol.for("undici.globalDispatcher.1");
|
|
40
|
+
let knownDefaultDispatcher;
|
|
41
|
+
function customDispatcher() {
|
|
42
|
+
const d = globalThis[DISPATCHER];
|
|
43
|
+
return d !== undefined && d !== knownDefaultDispatcher;
|
|
44
|
+
}
|
|
45
|
+
/** Calls the platform fetch; if that call is what created Node's default dispatcher, remember it
|
|
46
|
+
* so later calls still take the fast path. */
|
|
47
|
+
function platformFetch(input, init) {
|
|
48
|
+
const before = globalThis[DISPATCHER];
|
|
49
|
+
const p = globalThis.fetch(input, init);
|
|
50
|
+
if (before === undefined) {
|
|
51
|
+
const after = globalThis[DISPATCHER];
|
|
52
|
+
if (after !== undefined)
|
|
53
|
+
knownDefaultDispatcher = after;
|
|
54
|
+
}
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
58
|
+
export const nodeFetch = (input, init) => {
|
|
59
|
+
const fallback = () => platformFetch(input, init);
|
|
60
|
+
if (globalThis.fetch !== nativeFetch || proxyEnv() || customDispatcher())
|
|
61
|
+
return fallback();
|
|
62
|
+
if (init?.redirect && init.redirect !== "follow")
|
|
63
|
+
return fallback();
|
|
64
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : undefined;
|
|
65
|
+
const body = init?.body;
|
|
66
|
+
if (url === undefined || (body != null && typeof body !== "string" && !(body instanceof Uint8Array)))
|
|
67
|
+
return fallback();
|
|
68
|
+
const isHttps = url.startsWith("https:");
|
|
69
|
+
if (!isHttps && !url.startsWith("http:"))
|
|
70
|
+
return fallback();
|
|
71
|
+
const signal = init?.signal ?? undefined;
|
|
72
|
+
if (signal?.aborted)
|
|
73
|
+
return Promise.reject(signal.reason);
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const headers = toHeaderRecord(init?.headers);
|
|
76
|
+
if (body != null && !Object.keys(headers).some((k) => k.toLowerCase() === "content-length")) {
|
|
77
|
+
headers["content-length"] = String(typeof body === "string" ? Buffer.byteLength(body) : body.byteLength);
|
|
78
|
+
}
|
|
79
|
+
const mod = isHttps ? https : http;
|
|
80
|
+
let settled = false;
|
|
81
|
+
const onAbort = () => {
|
|
82
|
+
req.destroy(signal.reason);
|
|
83
|
+
if (!settled) {
|
|
84
|
+
settled = true;
|
|
85
|
+
reject(signal.reason);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const req = mod.request(url, { method: init?.method ?? "GET", headers, agent: isHttps ? httpsAgent : httpAgent }, (res) => {
|
|
89
|
+
const status = res.statusCode ?? 0;
|
|
90
|
+
if (REDIRECTS.has(status) && res.headers.location) {
|
|
91
|
+
// Follow it the way fetch does, from the redirect target: re-sending to the original URL
|
|
92
|
+
// would reach the server twice. 303, and 301/302 after a POST, become a bodiless GET;
|
|
93
|
+
// 307/308 repeat the method and body. Further hops are fetch's to follow.
|
|
94
|
+
res.resume();
|
|
95
|
+
signal?.removeEventListener("abort", onAbort);
|
|
96
|
+
settled = true;
|
|
97
|
+
const target = new URL(res.headers.location, url).href;
|
|
98
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
99
|
+
const toGet = status === 303 ? method !== "HEAD" : (status === 301 || status === 302) && method === "POST";
|
|
100
|
+
let next = init;
|
|
101
|
+
if (toGet) {
|
|
102
|
+
const h = toHeaderRecord(init?.headers);
|
|
103
|
+
for (const k of Object.keys(h)) {
|
|
104
|
+
if (/^content-(type|length|encoding|language|location)$/i.test(k))
|
|
105
|
+
delete h[k];
|
|
106
|
+
}
|
|
107
|
+
next = { ...init, method: "GET", body: undefined, headers: h };
|
|
108
|
+
}
|
|
109
|
+
platformFetch(target, next).then(resolve, reject);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const h = new Headers();
|
|
113
|
+
for (let i = 0; i < res.rawHeaders.length; i += 2)
|
|
114
|
+
h.append(res.rawHeaders[i], res.rawHeaders[i + 1]);
|
|
115
|
+
let stream = res;
|
|
116
|
+
const enc = (res.headers["content-encoding"] ?? "").toLowerCase();
|
|
117
|
+
if (enc === "gzip" || enc === "x-gzip" || enc === "deflate")
|
|
118
|
+
stream = res.pipe(zlib.createUnzip());
|
|
119
|
+
else if (enc === "br")
|
|
120
|
+
stream = res.pipe(zlib.createBrotliDecompress());
|
|
121
|
+
const nullBody = status === 204 || status === 304 || init?.method === "HEAD";
|
|
122
|
+
const ctype = (res.headers["content-type"] ?? "").toLowerCase();
|
|
123
|
+
const done = (b) => {
|
|
124
|
+
if (settled)
|
|
125
|
+
return;
|
|
126
|
+
settled = true;
|
|
127
|
+
resolve(new Response(nullBody ? null : b, { status, statusText: res.statusMessage, headers: h }));
|
|
128
|
+
};
|
|
129
|
+
if (ctype.includes("text/event-stream")) {
|
|
130
|
+
// Streamed: the body is consumed incrementally (SSE). Abort still destroys the socket.
|
|
131
|
+
stream.once("end", () => signal?.removeEventListener("abort", onAbort));
|
|
132
|
+
done(Readable.toWeb(stream));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const chunks = [];
|
|
136
|
+
stream.on("data", (c) => chunks.push(c));
|
|
137
|
+
stream.once("error", (e) => { if (!settled) {
|
|
138
|
+
settled = true;
|
|
139
|
+
reject(e);
|
|
140
|
+
} });
|
|
141
|
+
stream.once("end", () => {
|
|
142
|
+
signal?.removeEventListener("abort", onAbort);
|
|
143
|
+
done(Buffer.concat(chunks));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
req.once("error", (e) => {
|
|
147
|
+
signal?.removeEventListener("abort", onAbort);
|
|
148
|
+
if (!settled) {
|
|
149
|
+
settled = true;
|
|
150
|
+
reject(signal?.aborted ? signal.reason : new TypeError("fetch failed", { cause: e }));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
154
|
+
req.end(body ?? undefined);
|
|
155
|
+
});
|
|
156
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Tool } from "./types.js";
|
|
2
|
+
import type { ConversationEntry, ConversationStore } from "./client.js";
|
|
3
|
+
/** Options for {@link transcriptSearchTool}. */
|
|
4
|
+
export interface TranscriptSearchOptions {
|
|
5
|
+
/** Most hits returned per call (and the ceiling for the `limit` input). Default 20. */
|
|
6
|
+
maxResults?: number;
|
|
7
|
+
/** Snippet length in characters (Unicode code points, not bytes). Default 200. */
|
|
8
|
+
snippetChars?: number;
|
|
9
|
+
/** Tool name. Default `transcript_search`. */
|
|
10
|
+
name?: string;
|
|
11
|
+
}
|
|
12
|
+
/** A store that can enumerate its conversations — what transcript search requires. */
|
|
13
|
+
export type ListableConversationStore = ConversationStore & {
|
|
14
|
+
list(): Promise<ConversationEntry[]>;
|
|
15
|
+
};
|
|
16
|
+
export declare const TRANSCRIPT_SEARCH_DESCRIPTION: string;
|
|
17
|
+
/** The searchable text of one stored message (§7E): string content, text parts, tool_use
|
|
18
|
+
* input as compact JSON, tool_result content, then each OpenAI tool call's `arguments`
|
|
19
|
+
* string — pieces joined by "\n", then whitespace-normalized. Images are not text. */
|
|
20
|
+
export declare function messageText(msg: unknown): string;
|
|
21
|
+
/**
|
|
22
|
+
* A read-only {@link Tool} over `store` (§7E). Construction throws unless the store has
|
|
23
|
+
* `list()`. Input `{query, conversationId?, limit?}`; output one line per hit,
|
|
24
|
+
* `<conversationId>#<index> <role>: <snippet>`, `no matches` when none, and a trailing
|
|
25
|
+
* `(+<k> more)` when hits exceed the limit. Never writes.
|
|
26
|
+
*/
|
|
27
|
+
export declare function transcriptSearchTool(store: ConversationStore, opts?: TranscriptSearchOptions): Tool;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `transcriptSearchTool` — read-only search over a {@link ConversationStore} (SPEC.md §7E
|
|
3
|
+
* "Memory hierarchy" → transcript search). The ground-truth tier of agent memory: a dreaming
|
|
4
|
+
* persona checks its notes against what actually happened, not against its own notes.
|
|
5
|
+
*/
|
|
6
|
+
import { defineTool } from "./native.js";
|
|
7
|
+
export const TRANSCRIPT_SEARCH_DESCRIPTION = "Search past conversation transcripts — the ground truth of what actually happened. " +
|
|
8
|
+
"Case-insensitive substring match over message text. Returns one line per matching " +
|
|
9
|
+
"message: <conversationId>#<index> <role>: <snippet>. Read-only.";
|
|
10
|
+
const WS = /[ \t\n\r\f\v]+/g;
|
|
11
|
+
/** Collapse every run of ASCII whitespace to one space and trim (§7E). */
|
|
12
|
+
function normalize(s) {
|
|
13
|
+
return s.replace(WS, " ").replace(/^ | $/g, "");
|
|
14
|
+
}
|
|
15
|
+
/** ASCII-only case fold — never Unicode lowercasing (it changes lengths, differs per port). */
|
|
16
|
+
function fold(s) {
|
|
17
|
+
return s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
|
|
18
|
+
}
|
|
19
|
+
/** Code-point order (not UTF-16 code-unit order, which differs above U+FFFF). */
|
|
20
|
+
function byCodePoint(a, b) {
|
|
21
|
+
const x = Array.from(a);
|
|
22
|
+
const y = Array.from(b);
|
|
23
|
+
for (let i = 0; i < x.length && i < y.length; i++) {
|
|
24
|
+
const d = x[i].codePointAt(0) - y[i].codePointAt(0);
|
|
25
|
+
if (d !== 0)
|
|
26
|
+
return d;
|
|
27
|
+
}
|
|
28
|
+
return x.length - y.length;
|
|
29
|
+
}
|
|
30
|
+
/** A copy with every object's keys sorted by code point, recursively (§7E); arrays keep order. */
|
|
31
|
+
function sortKeys(v) {
|
|
32
|
+
if (Array.isArray(v))
|
|
33
|
+
return v.map(sortKeys);
|
|
34
|
+
if (v && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype) {
|
|
35
|
+
const o = v;
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const k of Object.keys(o).sort(byCodePoint))
|
|
38
|
+
out[k] = sortKeys(o[k]);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
return v;
|
|
42
|
+
}
|
|
43
|
+
function partsText(content) {
|
|
44
|
+
if (typeof content === "string")
|
|
45
|
+
return [content];
|
|
46
|
+
if (!Array.isArray(content))
|
|
47
|
+
return [];
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const p of content) {
|
|
50
|
+
if (!p || typeof p !== "object")
|
|
51
|
+
continue;
|
|
52
|
+
const part = p;
|
|
53
|
+
if (part.type === "text" && typeof part.text === "string")
|
|
54
|
+
out.push(part.text);
|
|
55
|
+
else if (part.type === "tool_use")
|
|
56
|
+
out.push(JSON.stringify(sortKeys(part.input ?? {})));
|
|
57
|
+
else if (part.type === "tool_result")
|
|
58
|
+
out.push(...partsText(part.content));
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** The searchable text of one stored message (§7E): string content, text parts, tool_use
|
|
63
|
+
* input as compact JSON, tool_result content, then each OpenAI tool call's `arguments`
|
|
64
|
+
* string — pieces joined by "\n", then whitespace-normalized. Images are not text. */
|
|
65
|
+
export function messageText(msg) {
|
|
66
|
+
if (!msg || typeof msg !== "object")
|
|
67
|
+
return "";
|
|
68
|
+
const m = msg;
|
|
69
|
+
const pieces = partsText(m.content);
|
|
70
|
+
if (Array.isArray(m.tool_calls)) {
|
|
71
|
+
for (const tc of m.tool_calls) {
|
|
72
|
+
const a = tc?.function?.arguments;
|
|
73
|
+
if (typeof a === "string")
|
|
74
|
+
pieces.push(a);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return normalize(pieces.join("\n"));
|
|
78
|
+
}
|
|
79
|
+
/** Snippet of `snippetChars` code points centred on the match; `…` marks elision. */
|
|
80
|
+
function snippet(cps, at, qlen, size) {
|
|
81
|
+
if (cps.length <= size)
|
|
82
|
+
return cps.join("");
|
|
83
|
+
let start = at + Math.floor(qlen / 2) - Math.floor(size / 2);
|
|
84
|
+
start = Math.max(0, Math.min(start, cps.length - size));
|
|
85
|
+
const end = start + size;
|
|
86
|
+
return (start > 0 ? "…" : "") + cps.slice(start, end).join("") + (end < cps.length ? "…" : "");
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A read-only {@link Tool} over `store` (§7E). Construction throws unless the store has
|
|
90
|
+
* `list()`. Input `{query, conversationId?, limit?}`; output one line per hit,
|
|
91
|
+
* `<conversationId>#<index> <role>: <snippet>`, `no matches` when none, and a trailing
|
|
92
|
+
* `(+<k> more)` when hits exceed the limit. Never writes.
|
|
93
|
+
*/
|
|
94
|
+
export function transcriptSearchTool(store, opts = {}) {
|
|
95
|
+
if (!store || typeof store.list !== "function") {
|
|
96
|
+
throw new Error("transcriptSearchTool: the store must implement list()");
|
|
97
|
+
}
|
|
98
|
+
const listable = store;
|
|
99
|
+
const maxResults = opts.maxResults ?? 20;
|
|
100
|
+
const snippetChars = opts.snippetChars ?? 200;
|
|
101
|
+
if (!Number.isInteger(maxResults) || maxResults < 1)
|
|
102
|
+
throw new Error("transcriptSearchTool: maxResults must be an integer >= 1");
|
|
103
|
+
if (!Number.isInteger(snippetChars) || snippetChars < 1)
|
|
104
|
+
throw new Error("transcriptSearchTool: snippetChars must be an integer >= 1");
|
|
105
|
+
return defineTool({
|
|
106
|
+
name: opts.name ?? "transcript_search",
|
|
107
|
+
description: TRANSCRIPT_SEARCH_DESCRIPTION,
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
query: { type: "string", description: "Text to find (case-insensitive substring)." },
|
|
112
|
+
conversationId: { type: "string", description: "Search only this conversation." },
|
|
113
|
+
limit: { type: "integer", description: `Most hits to return (default and max ${maxResults}).` },
|
|
114
|
+
},
|
|
115
|
+
required: ["query"],
|
|
116
|
+
},
|
|
117
|
+
run: async (args) => {
|
|
118
|
+
const query = typeof args.query === "string" ? normalize(args.query) : "";
|
|
119
|
+
if (query === "")
|
|
120
|
+
return { output: "query is required", isError: true };
|
|
121
|
+
let limit = maxResults;
|
|
122
|
+
if (args.limit !== undefined) {
|
|
123
|
+
if (!Number.isInteger(args.limit) || args.limit < 1) {
|
|
124
|
+
return { output: "limit must be an integer >= 1", isError: true };
|
|
125
|
+
}
|
|
126
|
+
limit = Math.min(args.limit, maxResults);
|
|
127
|
+
}
|
|
128
|
+
const ids = typeof args.conversationId === "string" ? [args.conversationId] : (await listable.list()).map((e) => e.id);
|
|
129
|
+
const q = Array.from(fold(query));
|
|
130
|
+
const hits = [];
|
|
131
|
+
let total = 0;
|
|
132
|
+
for (const id of ids) {
|
|
133
|
+
const msgs = await listable.get(id);
|
|
134
|
+
if (!msgs)
|
|
135
|
+
continue;
|
|
136
|
+
msgs.forEach((msg, index) => {
|
|
137
|
+
const cps = Array.from(messageText(msg));
|
|
138
|
+
const hay = Array.from(fold(cps.join("")));
|
|
139
|
+
const at = indexOfCps(hay, q);
|
|
140
|
+
if (at < 0)
|
|
141
|
+
return;
|
|
142
|
+
total++;
|
|
143
|
+
if (hits.length < limit) {
|
|
144
|
+
const role = typeof msg?.role === "string" ? msg.role : "unknown";
|
|
145
|
+
hits.push(`${id}#${index} ${role}: ${snippet(cps, at, q.length, snippetChars)}`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (total === 0)
|
|
150
|
+
return "no matches";
|
|
151
|
+
if (total > hits.length)
|
|
152
|
+
hits.push(`(+${total - hits.length} more)`);
|
|
153
|
+
return hits.join("\n");
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function indexOfCps(hay, needle) {
|
|
158
|
+
outer: for (let i = 0; i + needle.length <= hay.length; i++) {
|
|
159
|
+
for (let j = 0; j < needle.length; j++)
|
|
160
|
+
if (hay[i + j] !== needle[j])
|
|
161
|
+
continue outer;
|
|
162
|
+
return i;
|
|
163
|
+
}
|
|
164
|
+
return -1;
|
|
165
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolnexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc -p tsconfig.json",
|
|
20
|
-
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/classifier-backends.test.ts test/acp.test.ts test/acp-client.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts test/host-engine.test.ts test/host-turn-durability.test.ts test/host-engine-asks.test.ts test/tool-concurrency.test.ts test/media.test.ts test/examples.test.ts test/mcphandler.test.ts test/mcphandler-conformance.test.ts test/mcp2026.test.ts test/serve-mcp-conformance.test.ts",
|
|
20
|
+
"test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/agent-hooks.test.ts test/agent-inprocess.test.ts test/home.test.ts test/memory-hierarchy.test.ts test/compaction.test.ts test/loop.test.ts test/in-process.test.ts test/translate.test.ts test/multimodal.test.ts test/classifier.test.ts test/classifier-backends.test.ts test/acp.test.ts test/acp-client.test.ts test/issues.test.ts test/host-boundary.test.ts test/judge.test.ts test/batteries.test.ts test/host-engine.test.ts test/host-turn-durability.test.ts test/host-engine-asks.test.ts test/tool-concurrency.test.ts test/media.test.ts test/examples.test.ts test/mcphandler.test.ts test/mcphandler-conformance.test.ts test/mcp2026.test.ts test/serve-mcp-conformance.test.ts test/mock-llm.test.ts test/nodefetch.test.ts test/retry-stream.test.ts test/stop-recovery.test.ts test/round-boundary.test.ts test/reactive-compaction.test.ts test/fallback-models.test.ts",
|
|
21
21
|
"example": "node --experimental-strip-types examples/basic.ts",
|
|
22
22
|
"example:judge": "node --experimental-strip-types examples/judge.ts",
|
|
23
23
|
"example:multimodal": "node --experimental-strip-types examples/multimodal.ts",
|
package/src/acp.ts
CHANGED
|
@@ -108,6 +108,12 @@ export interface ACPClient {
|
|
|
108
108
|
emit: (event: StreamEvent) => void,
|
|
109
109
|
opts?: ACPGenerateOptions,
|
|
110
110
|
) => Promise<InProcessResponse>
|
|
111
|
+
/** Sends `session/set_mode {sessionId, modeId}` on the running session (no restart). The
|
|
112
|
+
* agent's error rejects this call only; the client stays usable. */
|
|
113
|
+
setMode: (mode: string) => Promise<void>
|
|
114
|
+
/** Changes the model on the running session exactly as the `model` option does:
|
|
115
|
+
* `session/set_config_option {configId:"model"}`, then `session/set_model`; both failing rejects. */
|
|
116
|
+
setModel: (model: string) => Promise<void>
|
|
111
117
|
/** Terminates the agent's process group. Idempotent — a second call is a no-op. */
|
|
112
118
|
close: () => Promise<void>
|
|
113
119
|
/** The agent's raw `initialize` result. */
|
|
@@ -366,20 +372,21 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
366
372
|
if (opts.mode) {
|
|
367
373
|
await step("session/set_mode", () => call("session/set_mode", { sessionId, modeId: opts.mode }))
|
|
368
374
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
375
|
+
// Passed through unchanged. Stable ACP spelling first, then the older session/set_model.
|
|
376
|
+
const sendModel = async (model: string): Promise<void> => {
|
|
377
|
+
try {
|
|
378
|
+
await call("session/set_config_option", { sessionId, configId: "model", value: model })
|
|
379
|
+
} catch (e1) {
|
|
373
380
|
try {
|
|
374
|
-
await call("session/
|
|
375
|
-
} catch (
|
|
376
|
-
|
|
377
|
-
await call("session/set_model", { sessionId, modelId: model })
|
|
378
|
-
} catch (e2) {
|
|
379
|
-
throw new Error(`${(e1 as Error).message}; ${(e2 as Error).message}`)
|
|
380
|
-
}
|
|
381
|
+
await call("session/set_model", { sessionId, modelId: model })
|
|
382
|
+
} catch (e2) {
|
|
383
|
+
throw new Error(`${(e1 as Error).message}; ${(e2 as Error).message}`)
|
|
381
384
|
}
|
|
382
|
-
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (opts.model) {
|
|
388
|
+
const model = opts.model
|
|
389
|
+
await step(`set model ${JSON.stringify(model)}`, () => sendModel(model))
|
|
383
390
|
}
|
|
384
391
|
|
|
385
392
|
// Serialises turns: one ACP session is one conversation, so concurrent
|
|
@@ -431,11 +438,18 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
431
438
|
break
|
|
432
439
|
}
|
|
433
440
|
case "tool_call_update": {
|
|
441
|
+
const callId: string = u.toolCallId ?? ""
|
|
442
|
+
// A non-empty title is remembered as the call's name for every later event (#188).
|
|
443
|
+
if (u.title) toolNames.set(callId, u.title)
|
|
444
|
+
const name: string = toolNames.get(callId) || ""
|
|
445
|
+
const raw = u.rawInput
|
|
446
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw) && Object.keys(raw).length > 0) {
|
|
447
|
+
send({ type: "tool_call_args", id: callId, name, args: raw, via: "acp" })
|
|
448
|
+
}
|
|
434
449
|
if (u.status !== "completed" && u.status !== "failed") break
|
|
435
|
-
const name: string = u.title || toolNames.get(u.toolCallId ?? "") || ""
|
|
436
450
|
send({
|
|
437
451
|
type: "tool_result",
|
|
438
|
-
id:
|
|
452
|
+
id: callId,
|
|
439
453
|
name,
|
|
440
454
|
output: acpToolContentText(u.content),
|
|
441
455
|
isError: u.status === "failed",
|
|
@@ -526,7 +540,20 @@ export async function loadACP(opts: ACPOptions): Promise<ACPClient> {
|
|
|
526
540
|
const generate = (request: InProcessRequest, callOpts?: ACPGenerateOptions): Promise<InProcessResponse> =>
|
|
527
541
|
generateStream(request, undefined, callOpts)
|
|
528
542
|
|
|
529
|
-
|
|
543
|
+
// SPEC §8C (#197): sent at once on the running session — no restart, not queued behind a turn.
|
|
544
|
+
const liveCall = async (label: string, fn: () => Promise<unknown>): Promise<void> => {
|
|
545
|
+
if (closed) throw new Error("toolnexus: ACP client is closed")
|
|
546
|
+
try {
|
|
547
|
+
await fn()
|
|
548
|
+
} catch (e) {
|
|
549
|
+
throw new Error(`toolnexus: acp: ${label}: ${e instanceof Error ? e.message : String(e)}`)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const setMode = (mode: string) =>
|
|
553
|
+
liveCall("session/set_mode", () => call("session/set_mode", { sessionId, modeId: mode }))
|
|
554
|
+
const setModel = (model: string) => liveCall(`set model ${JSON.stringify(model)}`, () => sendModel(model))
|
|
555
|
+
|
|
556
|
+
const client: ACPClient = { generate, generateStream, setMode, setModel, close, initializeResult }
|
|
530
557
|
// Non-enumerable, undocumented — internal test hook only, so the public
|
|
531
558
|
// ACPClient surface stays exactly {generate, generateStream, close}.
|
|
532
559
|
Object.defineProperty(client, "_pid", { value: child.pid, enumerable: false })
|