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/skill.ts
CHANGED
|
@@ -12,10 +12,19 @@
|
|
|
12
12
|
import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"
|
|
13
13
|
import path from "node:path"
|
|
14
14
|
import { pathToFileURL } from "node:url"
|
|
15
|
-
import {
|
|
15
|
+
import { createRequire } from "node:module"
|
|
16
16
|
import { compareCodePoints, compareDiscovery, sortEntriesByName } from "./order.js"
|
|
17
17
|
import type { Tool, ToolResult } from "./types.js"
|
|
18
18
|
|
|
19
|
+
// yaml is required on the first frontmatter parse, not at import, so a host with no skills never
|
|
20
|
+
// loads it. `require` keeps the parse synchronous (listSkills/loadSkills are sync); yaml's Node
|
|
21
|
+
// entry is CommonJS, so this is the same module instance an `import` would get.
|
|
22
|
+
let yamlParse: ((src: string) => unknown) | undefined
|
|
23
|
+
function parseYaml(src: string): unknown {
|
|
24
|
+
yamlParse ??= (createRequire(import.meta.url)("yaml") as typeof import("yaml")).parse
|
|
25
|
+
return yamlParse(src)
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
|
|
20
29
|
|
|
21
30
|
Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
|
|
@@ -68,10 +77,25 @@ export interface SkillSkip {
|
|
|
68
77
|
detail?: string
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
/** Why a KEPT skill carries a warning (SPEC.md §3, #84). The skill still loads unchanged. */
|
|
81
|
+
export type SkillWarningReason = "oversized-description"
|
|
82
|
+
|
|
83
|
+
/** Maximum skill `description` length, in Unicode code points, before a warning (SPEC.md §3). */
|
|
84
|
+
export const SKILL_DESCRIPTION_MAX_CHARS = 1024
|
|
85
|
+
|
|
86
|
+
export interface SkillWarning {
|
|
87
|
+
location: string
|
|
88
|
+
reason: SkillWarningReason
|
|
89
|
+
/** Description length in Unicode CODE POINTS (not bytes, not UTF-16 units). */
|
|
90
|
+
chars: number
|
|
91
|
+
}
|
|
92
|
+
|
|
71
93
|
/** Result of a list-only validate pass (SPEC.md §3, S3). */
|
|
72
94
|
export interface SkillInventory {
|
|
73
95
|
skills: SkillInfo[]
|
|
74
96
|
skipped: SkillSkip[]
|
|
97
|
+
/** Kept skills that load but carry a warning (oversized description), in discovery order. */
|
|
98
|
+
warnings: SkillWarning[]
|
|
75
99
|
}
|
|
76
100
|
|
|
77
101
|
/** Options for loadSkills / listSkills (SPEC.md §3, S1/S2/S5). */
|
|
@@ -353,6 +377,26 @@ function collectCandidates(opts: LoadSkillsOptions): RawCandidate[] {
|
|
|
353
377
|
return cands
|
|
354
378
|
}
|
|
355
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Warn — never modify — skills whose description exceeds SKILL_DESCRIPTION_MAX_CHARS code
|
|
382
|
+
* points (SPEC.md §3, #84). Each warning is also sent to the same channel as the
|
|
383
|
+
* duplicate-name warning. Order follows the given skills (discovery order).
|
|
384
|
+
*/
|
|
385
|
+
function describeWarnings(skills: SkillInfo[]): SkillWarning[] {
|
|
386
|
+
const out: SkillWarning[] = []
|
|
387
|
+
for (const s of skills) {
|
|
388
|
+
if (s.description === undefined) continue
|
|
389
|
+
const chars = [...s.description].length
|
|
390
|
+
if (chars > SKILL_DESCRIPTION_MAX_CHARS) {
|
|
391
|
+
console.warn(
|
|
392
|
+
`[toolnexus] skill "${s.name}" (${s.location}) description is ${chars} chars, over ${SKILL_DESCRIPTION_MAX_CHARS} — kept unmodified`,
|
|
393
|
+
)
|
|
394
|
+
out.push({ location: s.location, reason: "oversized-description", chars })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return out
|
|
398
|
+
}
|
|
399
|
+
|
|
356
400
|
/** Dedupe candidates by name (first-wins); later duplicates become skips. */
|
|
357
401
|
function mergeCandidates(cands: RawCandidate[]): { skills: Record<string, SkillInfo>; skipped: SkillSkip[] } {
|
|
358
402
|
const skills: Record<string, SkillInfo> = {}
|
|
@@ -409,6 +453,8 @@ export interface SkillSource {
|
|
|
409
453
|
* that stops being reported is indistinguishable from something that was finished.
|
|
410
454
|
*/
|
|
411
455
|
skipped: SkillSkip[]
|
|
456
|
+
/** Exposed (post-allowlist) skills whose description is oversized; never modified (#84). */
|
|
457
|
+
warnings: SkillWarning[]
|
|
412
458
|
}
|
|
413
459
|
|
|
414
460
|
/**
|
|
@@ -420,7 +466,8 @@ export function listSkills(input: string | string[] | LoadSkillsOptions): SkillI
|
|
|
420
466
|
const opts: LoadSkillsOptions =
|
|
421
467
|
typeof input === "string" || Array.isArray(input) ? { dirs: input } : input
|
|
422
468
|
const merged = mergeCandidates(collectCandidates(opts))
|
|
423
|
-
|
|
469
|
+
const list = Object.values(merged.skills)
|
|
470
|
+
return { skills: list, skipped: merged.skipped, warnings: describeWarnings(list) }
|
|
424
471
|
}
|
|
425
472
|
|
|
426
473
|
/** Discover skills (dirs and/or data) and build the `skill` loader tool. */
|
|
@@ -432,6 +479,7 @@ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillS
|
|
|
432
479
|
const skills = applySkillsFilter(merged.skills, opts.filter)
|
|
433
480
|
|
|
434
481
|
const skipped = merged.skipped
|
|
482
|
+
const warnings = describeWarnings(Object.values(skills))
|
|
435
483
|
const tool: Tool = {
|
|
436
484
|
name: "skill",
|
|
437
485
|
description: SKILL_TOOL_DESCRIPTION,
|
|
@@ -499,6 +547,7 @@ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillS
|
|
|
499
547
|
skills,
|
|
500
548
|
tool,
|
|
501
549
|
skipped,
|
|
550
|
+
warnings,
|
|
502
551
|
prompt() {
|
|
503
552
|
const described = Object.values(skills)
|
|
504
553
|
.filter((s) => s.description !== undefined)
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
import type { Tool } from "./types.js"
|
|
8
|
+
import type { ConversationEntry, ConversationStore } from "./client.js"
|
|
9
|
+
|
|
10
|
+
/** Options for {@link transcriptSearchTool}. */
|
|
11
|
+
export interface TranscriptSearchOptions {
|
|
12
|
+
/** Most hits returned per call (and the ceiling for the `limit` input). Default 20. */
|
|
13
|
+
maxResults?: number
|
|
14
|
+
/** Snippet length in characters (Unicode code points, not bytes). Default 200. */
|
|
15
|
+
snippetChars?: number
|
|
16
|
+
/** Tool name. Default `transcript_search`. */
|
|
17
|
+
name?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A store that can enumerate its conversations — what transcript search requires. */
|
|
21
|
+
export type ListableConversationStore = ConversationStore & { list(): Promise<ConversationEntry[]> }
|
|
22
|
+
|
|
23
|
+
export const TRANSCRIPT_SEARCH_DESCRIPTION =
|
|
24
|
+
"Search past conversation transcripts — the ground truth of what actually happened. " +
|
|
25
|
+
"Case-insensitive substring match over message text. Returns one line per matching " +
|
|
26
|
+
"message: <conversationId>#<index> <role>: <snippet>. Read-only."
|
|
27
|
+
|
|
28
|
+
const WS = /[ \t\n\r\f\v]+/g
|
|
29
|
+
|
|
30
|
+
/** Collapse every run of ASCII whitespace to one space and trim (§7E). */
|
|
31
|
+
function normalize(s: string): string {
|
|
32
|
+
return s.replace(WS, " ").replace(/^ | $/g, "")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** ASCII-only case fold — never Unicode lowercasing (it changes lengths, differs per port). */
|
|
36
|
+
function fold(s: string): string {
|
|
37
|
+
return s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Code-point order (not UTF-16 code-unit order, which differs above U+FFFF). */
|
|
41
|
+
function byCodePoint(a: string, b: string): number {
|
|
42
|
+
const x = Array.from(a)
|
|
43
|
+
const y = Array.from(b)
|
|
44
|
+
for (let i = 0; i < x.length && i < y.length; i++) {
|
|
45
|
+
const d = x[i].codePointAt(0)! - y[i].codePointAt(0)!
|
|
46
|
+
if (d !== 0) return d
|
|
47
|
+
}
|
|
48
|
+
return x.length - y.length
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A copy with every object's keys sorted by code point, recursively (§7E); arrays keep order. */
|
|
52
|
+
function sortKeys(v: unknown): unknown {
|
|
53
|
+
if (Array.isArray(v)) return v.map(sortKeys)
|
|
54
|
+
if (v && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype) {
|
|
55
|
+
const o = v as Record<string, unknown>
|
|
56
|
+
const out: Record<string, unknown> = {}
|
|
57
|
+
for (const k of Object.keys(o).sort(byCodePoint)) out[k] = sortKeys(o[k])
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
return v
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function partsText(content: unknown): string[] {
|
|
64
|
+
if (typeof content === "string") return [content]
|
|
65
|
+
if (!Array.isArray(content)) return []
|
|
66
|
+
const out: string[] = []
|
|
67
|
+
for (const p of content) {
|
|
68
|
+
if (!p || typeof p !== "object") continue
|
|
69
|
+
const part = p as Record<string, unknown>
|
|
70
|
+
if (part.type === "text" && typeof part.text === "string") out.push(part.text)
|
|
71
|
+
else if (part.type === "tool_use") out.push(JSON.stringify(sortKeys(part.input ?? {})))
|
|
72
|
+
else if (part.type === "tool_result") out.push(...partsText(part.content))
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The searchable text of one stored message (§7E): string content, text parts, tool_use
|
|
78
|
+
* input as compact JSON, tool_result content, then each OpenAI tool call's `arguments`
|
|
79
|
+
* string — pieces joined by "\n", then whitespace-normalized. Images are not text. */
|
|
80
|
+
export function messageText(msg: unknown): string {
|
|
81
|
+
if (!msg || typeof msg !== "object") return ""
|
|
82
|
+
const m = msg as Record<string, unknown>
|
|
83
|
+
const pieces = partsText(m.content)
|
|
84
|
+
if (Array.isArray(m.tool_calls)) {
|
|
85
|
+
for (const tc of m.tool_calls as any[]) {
|
|
86
|
+
const a = tc?.function?.arguments
|
|
87
|
+
if (typeof a === "string") pieces.push(a)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return normalize(pieces.join("\n"))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Snippet of `snippetChars` code points centred on the match; `…` marks elision. */
|
|
94
|
+
function snippet(cps: string[], at: number, qlen: number, size: number): string {
|
|
95
|
+
if (cps.length <= size) return cps.join("")
|
|
96
|
+
let start = at + Math.floor(qlen / 2) - Math.floor(size / 2)
|
|
97
|
+
start = Math.max(0, Math.min(start, cps.length - size))
|
|
98
|
+
const end = start + size
|
|
99
|
+
return (start > 0 ? "…" : "") + cps.slice(start, end).join("") + (end < cps.length ? "…" : "")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A read-only {@link Tool} over `store` (§7E). Construction throws unless the store has
|
|
104
|
+
* `list()`. Input `{query, conversationId?, limit?}`; output one line per hit,
|
|
105
|
+
* `<conversationId>#<index> <role>: <snippet>`, `no matches` when none, and a trailing
|
|
106
|
+
* `(+<k> more)` when hits exceed the limit. Never writes.
|
|
107
|
+
*/
|
|
108
|
+
export function transcriptSearchTool(store: ConversationStore, opts: TranscriptSearchOptions = {}): Tool {
|
|
109
|
+
if (!store || typeof (store as Partial<ListableConversationStore>).list !== "function") {
|
|
110
|
+
throw new Error("transcriptSearchTool: the store must implement list()")
|
|
111
|
+
}
|
|
112
|
+
const listable = store as ListableConversationStore
|
|
113
|
+
const maxResults = opts.maxResults ?? 20
|
|
114
|
+
const snippetChars = opts.snippetChars ?? 200
|
|
115
|
+
if (!Number.isInteger(maxResults) || maxResults < 1) throw new Error("transcriptSearchTool: maxResults must be an integer >= 1")
|
|
116
|
+
if (!Number.isInteger(snippetChars) || snippetChars < 1) throw new Error("transcriptSearchTool: snippetChars must be an integer >= 1")
|
|
117
|
+
return defineTool({
|
|
118
|
+
name: opts.name ?? "transcript_search",
|
|
119
|
+
description: TRANSCRIPT_SEARCH_DESCRIPTION,
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: {
|
|
123
|
+
query: { type: "string", description: "Text to find (case-insensitive substring)." },
|
|
124
|
+
conversationId: { type: "string", description: "Search only this conversation." },
|
|
125
|
+
limit: { type: "integer", description: `Most hits to return (default and max ${maxResults}).` },
|
|
126
|
+
},
|
|
127
|
+
required: ["query"],
|
|
128
|
+
},
|
|
129
|
+
run: async (args: Record<string, unknown>) => {
|
|
130
|
+
const query = typeof args.query === "string" ? normalize(args.query) : ""
|
|
131
|
+
if (query === "") return { output: "query is required", isError: true }
|
|
132
|
+
let limit = maxResults
|
|
133
|
+
if (args.limit !== undefined) {
|
|
134
|
+
if (!Number.isInteger(args.limit) || (args.limit as number) < 1) {
|
|
135
|
+
return { output: "limit must be an integer >= 1", isError: true }
|
|
136
|
+
}
|
|
137
|
+
limit = Math.min(args.limit as number, maxResults)
|
|
138
|
+
}
|
|
139
|
+
const ids =
|
|
140
|
+
typeof args.conversationId === "string" ? [args.conversationId] : (await listable.list()).map((e) => e.id)
|
|
141
|
+
const q = Array.from(fold(query))
|
|
142
|
+
const hits: string[] = []
|
|
143
|
+
let total = 0
|
|
144
|
+
for (const id of ids) {
|
|
145
|
+
const msgs = await listable.get(id)
|
|
146
|
+
if (!msgs) continue
|
|
147
|
+
msgs.forEach((msg, index) => {
|
|
148
|
+
const cps = Array.from(messageText(msg))
|
|
149
|
+
const hay = Array.from(fold(cps.join("")))
|
|
150
|
+
const at = indexOfCps(hay, q)
|
|
151
|
+
if (at < 0) return
|
|
152
|
+
total++
|
|
153
|
+
if (hits.length < limit) {
|
|
154
|
+
const role = typeof (msg as any)?.role === "string" ? (msg as any).role : "unknown"
|
|
155
|
+
hits.push(`${id}#${index} ${role}: ${snippet(cps, at, q.length, snippetChars)}`)
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
if (total === 0) return "no matches"
|
|
160
|
+
if (total > hits.length) hits.push(`(+${total - hits.length} more)`)
|
|
161
|
+
return hits.join("\n")
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function indexOfCps(hay: string[], needle: string[]): number {
|
|
167
|
+
outer: for (let i = 0; i + needle.length <= hay.length; i++) {
|
|
168
|
+
for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer
|
|
169
|
+
return i
|
|
170
|
+
}
|
|
171
|
+
return -1
|
|
172
|
+
}
|