@juspay/neurolink 9.84.2 → 9.85.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/CHANGELOG.md +6 -0
- package/dist/browser/neurolink.min.js +436 -401
- package/dist/cli/factories/commandFactory.d.ts +17 -0
- package/dist/cli/factories/commandFactory.js +254 -0
- package/dist/cli/parser.js +2 -0
- package/dist/cli/utils/skillsFlags.d.ts +10 -0
- package/dist/cli/utils/skillsFlags.js +22 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/lib/index.d.ts +5 -0
- package/dist/lib/index.js +6 -0
- package/dist/lib/neurolink.d.ts +28 -0
- package/dist/lib/neurolink.js +117 -0
- package/dist/lib/server/routes/agentRoutes.js +156 -1
- package/dist/lib/server/utils/validation.d.ts +32 -0
- package/dist/lib/server/utils/validation.js +18 -0
- package/dist/lib/session/globalSessionState.d.ts +10 -1
- package/dist/lib/session/globalSessionState.js +18 -0
- package/dist/lib/skills/skillMatcher.d.ts +20 -0
- package/dist/lib/skills/skillMatcher.js +80 -0
- package/dist/lib/skills/skillStoreRedis.d.ts +22 -0
- package/dist/lib/skills/skillStoreRedis.js +98 -0
- package/dist/lib/skills/skillStoreS3.d.ts +41 -0
- package/dist/lib/skills/skillStoreS3.js +233 -0
- package/dist/lib/skills/skillStores.d.ts +44 -0
- package/dist/lib/skills/skillStores.js +252 -0
- package/dist/lib/skills/skillTools.d.ts +19 -0
- package/dist/lib/skills/skillTools.js +340 -0
- package/dist/lib/skills/skillsManager.d.ts +54 -0
- package/dist/lib/skills/skillsManager.js +220 -0
- package/dist/lib/types/config.d.ts +10 -0
- package/dist/lib/types/generate.d.ts +8 -0
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.js +1 -0
- package/dist/lib/types/skills.d.ts +296 -0
- package/dist/lib/types/skills.js +17 -0
- package/dist/lib/types/stream.d.ts +8 -0
- package/dist/neurolink.d.ts +28 -0
- package/dist/neurolink.js +117 -0
- package/dist/server/routes/agentRoutes.js +156 -1
- package/dist/server/utils/validation.d.ts +32 -0
- package/dist/server/utils/validation.js +18 -0
- package/dist/session/globalSessionState.d.ts +10 -1
- package/dist/session/globalSessionState.js +18 -0
- package/dist/skills/skillMatcher.d.ts +20 -0
- package/dist/skills/skillMatcher.js +79 -0
- package/dist/skills/skillStoreRedis.d.ts +22 -0
- package/dist/skills/skillStoreRedis.js +97 -0
- package/dist/skills/skillStoreS3.d.ts +41 -0
- package/dist/skills/skillStoreS3.js +232 -0
- package/dist/skills/skillStores.d.ts +44 -0
- package/dist/skills/skillStores.js +251 -0
- package/dist/skills/skillTools.d.ts +19 -0
- package/dist/skills/skillTools.js +339 -0
- package/dist/skills/skillsManager.d.ts +54 -0
- package/dist/skills/skillsManager.js +219 -0
- package/dist/types/config.d.ts +10 -0
- package/dist/types/generate.d.ts +8 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/skills.d.ts +296 -0
- package/dist/types/skills.js +16 -0
- package/dist/types/stream.d.ts +8 -0
- package/package.json +2 -1
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in skill tools, following the createMemoryRetrievalTools /
|
|
3
|
+
* createFileTools / createTaskTools factory pattern.
|
|
4
|
+
*
|
|
5
|
+
* The manager is resolved lazily via a resolver callback so tools can be
|
|
6
|
+
* registered at construction time while the store initializes on first
|
|
7
|
+
* use (mirrors how retrieve_context resolves conversationMemory lazily).
|
|
8
|
+
*
|
|
9
|
+
* Tool descriptions are ported from curator's production skills tools —
|
|
10
|
+
* the "always check skills first / no_match is expected, not an error /
|
|
11
|
+
* pick the best of 2-5 or ask" prompt engineering is proven in prod.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { logger } from "../utils/logger.js";
|
|
15
|
+
import { tool } from "../utils/tool.js";
|
|
16
|
+
/** Trim a hydrated skill to the fields the model needs. */
|
|
17
|
+
function toToolSkill(skill) {
|
|
18
|
+
return {
|
|
19
|
+
id: skill.id,
|
|
20
|
+
name: skill.name,
|
|
21
|
+
...(skill.displayName ? { displayName: skill.displayName } : {}),
|
|
22
|
+
description: skill.description,
|
|
23
|
+
instructions: skill.instructions,
|
|
24
|
+
tags: skill.tags ?? [],
|
|
25
|
+
scope: skill.scope ?? "global",
|
|
26
|
+
version: skill.version ?? 1,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Create the built-in skill tools bound to a lazily-resolved manager.
|
|
31
|
+
* Returns Vercel AI SDK tool() objects (description + Zod inputSchema +
|
|
32
|
+
* execute) keyed by tool name.
|
|
33
|
+
*/
|
|
34
|
+
export function createSkillTools(resolveManager, options) {
|
|
35
|
+
const notConfigured = {
|
|
36
|
+
success: false,
|
|
37
|
+
error: "Skills are not available — the skills store failed to initialize. " +
|
|
38
|
+
"Answer from general knowledge.",
|
|
39
|
+
data: { skills: [] },
|
|
40
|
+
};
|
|
41
|
+
const tools = {
|
|
42
|
+
search_skills: tool({
|
|
43
|
+
description: "ALWAYS call this at the start of every user request before answering from general knowledge. " +
|
|
44
|
+
"Searches team-defined skills (SOPs, playbooks, workflows) using an index-first approach — " +
|
|
45
|
+
"fast, low-cost, and does not load skills that do not match. " +
|
|
46
|
+
"You MUST provide at least one of: query (keyword from the user message) or tag (domain category). " +
|
|
47
|
+
"If 1 match is found, follow its instructions exactly. " +
|
|
48
|
+
"If 2-5 matches are found, use your judgment based on the user message to pick the best one, " +
|
|
49
|
+
"or ask the user to clarify if the instructions would lead to meaningfully different actions. " +
|
|
50
|
+
"Only skip this call for purely conversational messages like greetings. " +
|
|
51
|
+
'Returns `{ skills: [], reason: "no_match" }` when nothing matches — this is expected, NOT an ' +
|
|
52
|
+
"error. In that case, answer from general knowledge.",
|
|
53
|
+
inputSchema: z.object({
|
|
54
|
+
query: z
|
|
55
|
+
.string()
|
|
56
|
+
.optional()
|
|
57
|
+
.describe("Keyword from the user message, matched against skill name, display name, and description. " +
|
|
58
|
+
'E.g. "deployment", "refund process", "on-call escalation".'),
|
|
59
|
+
tag: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.describe('Domain category tag to narrow results (e.g. "payments", "devops"). ' +
|
|
63
|
+
"Applied on top of the query filter when both are provided."),
|
|
64
|
+
scopeId: z
|
|
65
|
+
.string()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe("Scope identifier (channel/team/tenant id) to include scoped skills alongside global ones. " +
|
|
68
|
+
"Usually omit — the host default applies."),
|
|
69
|
+
}),
|
|
70
|
+
execute: async (args) => {
|
|
71
|
+
if (!args.query && !args.tag) {
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
error: 'At least one of "query" or "tag" must be provided to search_skills.',
|
|
75
|
+
data: { skills: [] },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const manager = resolveManager();
|
|
79
|
+
if (!manager) {
|
|
80
|
+
return notConfigured;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const skills = await manager.search(args);
|
|
84
|
+
if (skills.length === 0) {
|
|
85
|
+
return {
|
|
86
|
+
success: true,
|
|
87
|
+
data: {
|
|
88
|
+
skills: [],
|
|
89
|
+
reason: "no_match",
|
|
90
|
+
message: `No skills found${args.query ? ` matching "${args.query}"` : ""}${args.tag ? ` with tag "${args.tag}"` : ""}. Answer from general knowledge.`,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
success: true,
|
|
96
|
+
data: {
|
|
97
|
+
skills: skills.map(toToolSkill),
|
|
98
|
+
count: skills.length,
|
|
99
|
+
...(skills.length > 1
|
|
100
|
+
? {
|
|
101
|
+
hint: "Multiple skills matched. Pick the most relevant one for the user message, or ask the user to clarify.",
|
|
102
|
+
}
|
|
103
|
+
: {}),
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
logger.warn("[SkillTools] search_skills failed", {
|
|
109
|
+
error: error instanceof Error ? error.message : String(error),
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
success: false,
|
|
113
|
+
error: error instanceof Error ? error.message : String(error),
|
|
114
|
+
data: { skills: [] },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
}),
|
|
119
|
+
list_skills: tool({
|
|
120
|
+
description: "Returns a lightweight list of all available skills — name, display name, description, and " +
|
|
121
|
+
"tags only. No instructions are returned, keeping context cost minimal. " +
|
|
122
|
+
'Use this only when a user explicitly asks "what skills do you have?" or "what can you help ' +
|
|
123
|
+
'me with?". Do NOT use this for skill lookup before answering — use search_skills instead.',
|
|
124
|
+
inputSchema: z.object({
|
|
125
|
+
scopeId: z
|
|
126
|
+
.string()
|
|
127
|
+
.optional()
|
|
128
|
+
.describe("Scope identifier to include scoped skills alongside global ones. Usually omit."),
|
|
129
|
+
}),
|
|
130
|
+
execute: async (args) => {
|
|
131
|
+
const manager = resolveManager();
|
|
132
|
+
if (!manager) {
|
|
133
|
+
return notConfigured;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const items = await manager.list(args.scopeId);
|
|
137
|
+
return {
|
|
138
|
+
success: true,
|
|
139
|
+
data: {
|
|
140
|
+
skills: items.map((item) => ({
|
|
141
|
+
name: item.name,
|
|
142
|
+
...(item.displayName ? { displayName: item.displayName } : {}),
|
|
143
|
+
description: item.description,
|
|
144
|
+
tags: item.tags ?? [],
|
|
145
|
+
scope: item.scope ?? "global",
|
|
146
|
+
})),
|
|
147
|
+
count: items.length,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
logger.warn("[SkillTools] list_skills failed", {
|
|
153
|
+
error: error instanceof Error ? error.message : String(error),
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
success: false,
|
|
157
|
+
error: error instanceof Error ? error.message : String(error),
|
|
158
|
+
data: { skills: [] },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
};
|
|
164
|
+
if (options?.allowMutations) {
|
|
165
|
+
Object.assign(tools, createSkillMutationTools(resolveManager));
|
|
166
|
+
}
|
|
167
|
+
return tools;
|
|
168
|
+
}
|
|
169
|
+
/** Shared executor for the three mutation tools. */
|
|
170
|
+
async function runMutation(resolveManager, action) {
|
|
171
|
+
const manager = resolveManager();
|
|
172
|
+
if (!manager) {
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
error: "Skills are not available — the skills store failed to initialize.",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const result = await manager.requestMutation(action);
|
|
180
|
+
if (result.decision.outcome === "rejected") {
|
|
181
|
+
return {
|
|
182
|
+
success: false,
|
|
183
|
+
error: `Skill ${action.type} was rejected${result.decision.reason ? `: ${result.decision.reason}` : "."}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (result.decision.outcome === "pending") {
|
|
187
|
+
return {
|
|
188
|
+
success: true,
|
|
189
|
+
data: {
|
|
190
|
+
status: "pending_approval",
|
|
191
|
+
...(result.decision.reference
|
|
192
|
+
? { reference: result.decision.reference }
|
|
193
|
+
: {}),
|
|
194
|
+
message: `Skill ${action.type} was submitted for approval. It takes effect once an approver accepts it.`,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
success: true,
|
|
200
|
+
data: {
|
|
201
|
+
status: "applied",
|
|
202
|
+
...(result.skill
|
|
203
|
+
? {
|
|
204
|
+
skillId: result.skill.id,
|
|
205
|
+
name: result.skill.name,
|
|
206
|
+
version: result.skill.version,
|
|
207
|
+
skillStatus: result.skill.status,
|
|
208
|
+
}
|
|
209
|
+
: {}),
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
logger.warn(`[SkillTools] skill_${action.type} failed`, {
|
|
215
|
+
error: error instanceof Error ? error.message : String(error),
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
success: false,
|
|
219
|
+
error: error instanceof Error ? error.message : String(error),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function createSkillMutationTools(resolveManager) {
|
|
224
|
+
const scopeFields = {
|
|
225
|
+
scope: z
|
|
226
|
+
.enum(["global", "scoped"])
|
|
227
|
+
.optional()
|
|
228
|
+
.describe('"global" = available everywhere; "scoped" = only for specific scope ids.'),
|
|
229
|
+
scopeIds: z
|
|
230
|
+
.array(z.string())
|
|
231
|
+
.optional()
|
|
232
|
+
.describe('Required when scope="scoped": the scope ids (channel/team/tenant) the skill applies to.'),
|
|
233
|
+
};
|
|
234
|
+
return {
|
|
235
|
+
skill_create: tool({
|
|
236
|
+
description: "Propose a NEW skill (SOP/playbook). Depending on host configuration the skill is either " +
|
|
237
|
+
"applied immediately or queued for human approval. " +
|
|
238
|
+
"INVOKE ONLY WHEN the user explicitly asks to create a new skill and has supplied the name, " +
|
|
239
|
+
"description, and full instructions text. DO NOT INVENT CONTENT — the instructions must come " +
|
|
240
|
+
"from the user verbatim or nearly so. If the user has not given the full text, ask. " +
|
|
241
|
+
"Do not use this to modify an existing skill (use skill_update).",
|
|
242
|
+
inputSchema: z.object({
|
|
243
|
+
name: z
|
|
244
|
+
.string()
|
|
245
|
+
.min(1)
|
|
246
|
+
.describe('Short machine-friendly skill name (snake_case recommended), unique. E.g. "refund_dispute_escalation".'),
|
|
247
|
+
displayName: z
|
|
248
|
+
.string()
|
|
249
|
+
.optional()
|
|
250
|
+
.describe('Human-readable display name. E.g. "Refund Dispute Escalation".'),
|
|
251
|
+
description: z
|
|
252
|
+
.string()
|
|
253
|
+
.min(1)
|
|
254
|
+
.describe("One or two sentences explaining when this skill applies. Used for matching."),
|
|
255
|
+
instructions: z
|
|
256
|
+
.string()
|
|
257
|
+
.min(1)
|
|
258
|
+
.describe("The full step-by-step instructions to follow when this skill matches. " +
|
|
259
|
+
"Must come from the user — do not invent steps."),
|
|
260
|
+
tags: z
|
|
261
|
+
.array(z.string())
|
|
262
|
+
.optional()
|
|
263
|
+
.describe('Domain tags for filtering (e.g. ["payments", "escalation"]).'),
|
|
264
|
+
...scopeFields,
|
|
265
|
+
requestedBy: z
|
|
266
|
+
.string()
|
|
267
|
+
.optional()
|
|
268
|
+
.describe("Identifier of the requesting user, when known."),
|
|
269
|
+
}),
|
|
270
|
+
execute: async (args) => {
|
|
271
|
+
if (args.scope === "scoped" && (args.scopeIds ?? []).length === 0) {
|
|
272
|
+
return {
|
|
273
|
+
success: false,
|
|
274
|
+
error: '"scopeIds" must contain at least one scope id when scope="scoped".',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const { requestedBy, ...skill } = args;
|
|
278
|
+
return runMutation(resolveManager, {
|
|
279
|
+
type: "create",
|
|
280
|
+
skill,
|
|
281
|
+
...(requestedBy ? { requestedBy } : {}),
|
|
282
|
+
});
|
|
283
|
+
},
|
|
284
|
+
}),
|
|
285
|
+
skill_update: tool({
|
|
286
|
+
description: "Propose an update to an EXISTING skill. Only the provided fields change; the version is " +
|
|
287
|
+
"bumped. Depending on host configuration the change is applied immediately or queued for " +
|
|
288
|
+
"human approval. Look the skill up with search_skills or list_skills first to get its id or " +
|
|
289
|
+
"exact name. Do not paraphrase or expand the user's instructions.",
|
|
290
|
+
inputSchema: z.object({
|
|
291
|
+
skillId: z
|
|
292
|
+
.string()
|
|
293
|
+
.min(1)
|
|
294
|
+
.describe("Id (or exact unique name) of the skill to update."),
|
|
295
|
+
displayName: z.string().optional(),
|
|
296
|
+
description: z.string().optional(),
|
|
297
|
+
instructions: z
|
|
298
|
+
.string()
|
|
299
|
+
.optional()
|
|
300
|
+
.describe("Replacement instructions text, verbatim from the user."),
|
|
301
|
+
tags: z.array(z.string()).optional(),
|
|
302
|
+
...scopeFields,
|
|
303
|
+
requestedBy: z.string().optional(),
|
|
304
|
+
}),
|
|
305
|
+
execute: async (args) => {
|
|
306
|
+
const { skillId, requestedBy, ...patch } = args;
|
|
307
|
+
if (Object.values(patch).every((v) => v === undefined)) {
|
|
308
|
+
return {
|
|
309
|
+
success: false,
|
|
310
|
+
error: "Provide at least one field to update.",
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return runMutation(resolveManager, {
|
|
314
|
+
type: "update",
|
|
315
|
+
skillId,
|
|
316
|
+
patch,
|
|
317
|
+
...(requestedBy ? { requestedBy } : {}),
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
}),
|
|
321
|
+
skill_delete: tool({
|
|
322
|
+
description: "Propose deleting (deprecating) an EXISTING skill. The skill stops matching but stays in " +
|
|
323
|
+
"storage for audit. Depending on host configuration this is applied immediately or queued " +
|
|
324
|
+
"for human approval. INVOKE ONLY WHEN the user explicitly asks to delete a skill.",
|
|
325
|
+
inputSchema: z.object({
|
|
326
|
+
skillId: z
|
|
327
|
+
.string()
|
|
328
|
+
.min(1)
|
|
329
|
+
.describe("Id (or exact unique name) of the skill to delete."),
|
|
330
|
+
requestedBy: z.string().optional(),
|
|
331
|
+
}),
|
|
332
|
+
execute: async (args) => runMutation(resolveManager, {
|
|
333
|
+
type: "delete",
|
|
334
|
+
skillId: args.skillId,
|
|
335
|
+
...(args.requestedBy ? { requestedBy: args.requestedBy } : {}),
|
|
336
|
+
}),
|
|
337
|
+
}),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillsManager — orchestrates the skill store, cached index, index-first
|
|
3
|
+
* search (hydrating instructions only for matches), prompt-index rendering,
|
|
4
|
+
* and the mutation gate.
|
|
5
|
+
*
|
|
6
|
+
* Read paths fail open (errors surface as empty results + a warn log);
|
|
7
|
+
* write paths fail closed (errors reject the mutation).
|
|
8
|
+
*/
|
|
9
|
+
import type { SkillDefinition, SkillIndexItem, SkillMutationAction, SkillMutationResult, SkillSearchQuery, SkillsConfig } from "../types/index.js";
|
|
10
|
+
export declare class SkillsManager {
|
|
11
|
+
private readonly config;
|
|
12
|
+
private readonly store;
|
|
13
|
+
private cachedIndex;
|
|
14
|
+
private cachedIndexAt;
|
|
15
|
+
/** Serializes writes within this process — see requestMutation(). */
|
|
16
|
+
private mutationQueue;
|
|
17
|
+
constructor(config: SkillsConfig);
|
|
18
|
+
/** Cached index read. TTL 0 disables caching. */
|
|
19
|
+
getIndex(forceRefresh?: boolean): Promise<SkillIndexItem[]>;
|
|
20
|
+
private invalidateIndex;
|
|
21
|
+
/**
|
|
22
|
+
* Index-first search: filter the cached index, hydrate only the matched
|
|
23
|
+
* entries (max `limit`) with instructions. Cost: one cached index read +
|
|
24
|
+
* N_matched store gets.
|
|
25
|
+
*/
|
|
26
|
+
search(query: SkillSearchQuery): Promise<SkillDefinition[]>;
|
|
27
|
+
/** Index entries only — no instructions. For discovery/listing. */
|
|
28
|
+
list(scopeId?: string): Promise<SkillIndexItem[]>;
|
|
29
|
+
/** Fetch one skill by id, falling back to name lookup. */
|
|
30
|
+
get(idOrName: string): Promise<SkillDefinition | null>;
|
|
31
|
+
/**
|
|
32
|
+
* Render the system-prompt skills index for one call, or null when
|
|
33
|
+
* nothing is visible. Never includes instructions.
|
|
34
|
+
*/
|
|
35
|
+
buildPromptIndex(options?: {
|
|
36
|
+
scopeId?: string;
|
|
37
|
+
tags?: string[];
|
|
38
|
+
}): Promise<string | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Whether skill create/update/delete is enabled on this instance. Gates the
|
|
41
|
+
* LLM-facing `skill_*` tools (registration) and the server REST mutation
|
|
42
|
+
* routes. Direct programmatic `requestMutation` calls are intentionally not
|
|
43
|
+
* gated, so a host can still seed skills at startup.
|
|
44
|
+
*/
|
|
45
|
+
get mutationsAllowed(): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Gate a proposed mutation through the host's onMutationRequest hook,
|
|
48
|
+
* then apply it when approved. No hook configured means direct apply
|
|
49
|
+
* (the tools themselves are already gated by allowMutations).
|
|
50
|
+
*/
|
|
51
|
+
requestMutation(action: SkillMutationAction): Promise<SkillMutationResult>;
|
|
52
|
+
private applyMutation;
|
|
53
|
+
private assertNameAvailable;
|
|
54
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SkillsManager — orchestrates the skill store, cached index, index-first
|
|
3
|
+
* search (hydrating instructions only for matches), prompt-index rendering,
|
|
4
|
+
* and the mutation gate.
|
|
5
|
+
*
|
|
6
|
+
* Read paths fail open (errors surface as empty results + a warn log);
|
|
7
|
+
* write paths fail closed (errors reject the mutation).
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { logger } from "../utils/logger.js";
|
|
11
|
+
import { filterSkillIndex, formatSkillsPromptIndex } from "./skillMatcher.js";
|
|
12
|
+
import { createSkillStore } from "./skillStores.js";
|
|
13
|
+
const DEFAULT_MAX_MATCHES = 5;
|
|
14
|
+
const DEFAULT_PROMPT_INDEX_MAX_ITEMS = 50;
|
|
15
|
+
const DEFAULT_INDEX_CACHE_TTL_MS = 30_000;
|
|
16
|
+
export class SkillsManager {
|
|
17
|
+
config;
|
|
18
|
+
store;
|
|
19
|
+
cachedIndex = null;
|
|
20
|
+
cachedIndexAt = 0;
|
|
21
|
+
/** Serializes writes within this process — see requestMutation(). */
|
|
22
|
+
mutationQueue = Promise.resolve();
|
|
23
|
+
constructor(config) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.store = createSkillStore(config.storage);
|
|
26
|
+
}
|
|
27
|
+
/** Cached index read. TTL 0 disables caching. */
|
|
28
|
+
async getIndex(forceRefresh = false) {
|
|
29
|
+
const ttl = this.config.indexCacheTtlMs ?? DEFAULT_INDEX_CACHE_TTL_MS;
|
|
30
|
+
const fresh = this.cachedIndex !== null &&
|
|
31
|
+
ttl > 0 &&
|
|
32
|
+
Date.now() - this.cachedIndexAt < ttl;
|
|
33
|
+
if (fresh && !forceRefresh && this.cachedIndex) {
|
|
34
|
+
return this.cachedIndex;
|
|
35
|
+
}
|
|
36
|
+
const index = await this.store.index();
|
|
37
|
+
this.cachedIndex = index;
|
|
38
|
+
this.cachedIndexAt = Date.now();
|
|
39
|
+
return index;
|
|
40
|
+
}
|
|
41
|
+
invalidateIndex() {
|
|
42
|
+
this.cachedIndex = null;
|
|
43
|
+
this.store.invalidate?.();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Index-first search: filter the cached index, hydrate only the matched
|
|
47
|
+
* entries (max `limit`) with instructions. Cost: one cached index read +
|
|
48
|
+
* N_matched store gets.
|
|
49
|
+
*/
|
|
50
|
+
async search(query) {
|
|
51
|
+
const limit = query.limit ?? this.config.maxMatches ?? DEFAULT_MAX_MATCHES;
|
|
52
|
+
const scopeId = query.scopeId ?? this.config.defaultScopeId;
|
|
53
|
+
const index = await this.getIndex();
|
|
54
|
+
const matched = filterSkillIndex(index, {
|
|
55
|
+
...query,
|
|
56
|
+
...(scopeId !== undefined ? { scopeId } : {}),
|
|
57
|
+
limit,
|
|
58
|
+
});
|
|
59
|
+
const hydrated = await Promise.all(matched.map((item) => this.store.get(item.id)));
|
|
60
|
+
return hydrated.filter((s) => s !== null);
|
|
61
|
+
}
|
|
62
|
+
/** Index entries only — no instructions. For discovery/listing. */
|
|
63
|
+
async list(scopeId) {
|
|
64
|
+
const effectiveScopeId = scopeId ?? this.config.defaultScopeId;
|
|
65
|
+
const index = await this.getIndex();
|
|
66
|
+
return filterSkillIndex(index, {
|
|
67
|
+
...(effectiveScopeId !== undefined ? { scopeId: effectiveScopeId } : {}),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Fetch one skill by id, falling back to name lookup. */
|
|
71
|
+
async get(idOrName) {
|
|
72
|
+
const byId = await this.store.get(idOrName);
|
|
73
|
+
if (byId) {
|
|
74
|
+
return byId;
|
|
75
|
+
}
|
|
76
|
+
const index = await this.getIndex();
|
|
77
|
+
// Prefer an active skill when resolving by name: a soft-deleted skill and a
|
|
78
|
+
// later same-named active skill can coexist in the index, so a bare
|
|
79
|
+
// name-find would resolve non-deterministically to the stale deprecated one
|
|
80
|
+
// across store backends. Fall back to any match only when no active skill
|
|
81
|
+
// carries the name.
|
|
82
|
+
const entry = index.find((item) => item.name === idOrName && (item.status ?? "active") === "active") ?? index.find((item) => item.name === idOrName);
|
|
83
|
+
return entry ? this.store.get(entry.id) : null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Render the system-prompt skills index for one call, or null when
|
|
87
|
+
* nothing is visible. Never includes instructions.
|
|
88
|
+
*/
|
|
89
|
+
async buildPromptIndex(options) {
|
|
90
|
+
let items = await this.list(options?.scopeId);
|
|
91
|
+
if (options?.tags && options.tags.length > 0) {
|
|
92
|
+
const wanted = options.tags.map((t) => t.toLowerCase());
|
|
93
|
+
items = items.filter((item) => (item.tags ?? []).some((t) => wanted.includes(t.toLowerCase())));
|
|
94
|
+
}
|
|
95
|
+
return formatSkillsPromptIndex(items, this.config.promptIndexMaxItems ?? DEFAULT_PROMPT_INDEX_MAX_ITEMS);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether skill create/update/delete is enabled on this instance. Gates the
|
|
99
|
+
* LLM-facing `skill_*` tools (registration) and the server REST mutation
|
|
100
|
+
* routes. Direct programmatic `requestMutation` calls are intentionally not
|
|
101
|
+
* gated, so a host can still seed skills at startup.
|
|
102
|
+
*/
|
|
103
|
+
get mutationsAllowed() {
|
|
104
|
+
return this.config.allowMutations ?? false;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Gate a proposed mutation through the host's onMutationRequest hook,
|
|
108
|
+
* then apply it when approved. No hook configured means direct apply
|
|
109
|
+
* (the tools themselves are already gated by allowMutations).
|
|
110
|
+
*/
|
|
111
|
+
async requestMutation(action) {
|
|
112
|
+
let decision = { outcome: "approved" };
|
|
113
|
+
if (this.config.onMutationRequest) {
|
|
114
|
+
decision = await this.config.onMutationRequest(action);
|
|
115
|
+
}
|
|
116
|
+
if (decision.outcome !== "approved") {
|
|
117
|
+
return { decision };
|
|
118
|
+
}
|
|
119
|
+
// Serialize writes within this process so the name-uniqueness check and
|
|
120
|
+
// the store write can never interleave across concurrent mutations.
|
|
121
|
+
// Cross-process races remain possible with plain-object stores (S3,
|
|
122
|
+
// Redis) — hosts needing strict global uniqueness should serialize via
|
|
123
|
+
// their onMutationRequest gate; the S3 index additionally self-heals.
|
|
124
|
+
const run = this.mutationQueue.then(() => this.applyMutation(action));
|
|
125
|
+
this.mutationQueue = run.catch(() => undefined);
|
|
126
|
+
const skill = await run;
|
|
127
|
+
return { decision, ...(skill ? { skill } : {}) };
|
|
128
|
+
}
|
|
129
|
+
async applyMutation(action) {
|
|
130
|
+
const now = new Date().toISOString();
|
|
131
|
+
if (action.type === "create") {
|
|
132
|
+
await this.assertNameAvailable(action.skill.name);
|
|
133
|
+
const skill = {
|
|
134
|
+
id: randomUUID(),
|
|
135
|
+
version: 1,
|
|
136
|
+
status: "active",
|
|
137
|
+
createdAt: now,
|
|
138
|
+
updatedAt: now,
|
|
139
|
+
...action.skill,
|
|
140
|
+
};
|
|
141
|
+
assertScopeConsistent(skill);
|
|
142
|
+
await this.store.put(skill);
|
|
143
|
+
this.invalidateIndex();
|
|
144
|
+
logger.info("[SkillsManager] Skill created", {
|
|
145
|
+
skillId: skill.id,
|
|
146
|
+
name: skill.name,
|
|
147
|
+
});
|
|
148
|
+
return skill;
|
|
149
|
+
}
|
|
150
|
+
if (action.type === "update") {
|
|
151
|
+
const existing = await this.get(action.skillId);
|
|
152
|
+
if (!existing) {
|
|
153
|
+
throw new Error(`Skill "${action.skillId}" not found`);
|
|
154
|
+
}
|
|
155
|
+
if (action.patch.name && action.patch.name !== existing.name) {
|
|
156
|
+
await this.assertNameAvailable(action.patch.name, existing.id);
|
|
157
|
+
}
|
|
158
|
+
const updated = {
|
|
159
|
+
...existing,
|
|
160
|
+
...definedFields(action.patch),
|
|
161
|
+
id: existing.id,
|
|
162
|
+
version: (existing.version ?? 1) + 1,
|
|
163
|
+
updatedAt: now,
|
|
164
|
+
};
|
|
165
|
+
assertScopeConsistent(updated);
|
|
166
|
+
await this.store.put(updated);
|
|
167
|
+
this.invalidateIndex();
|
|
168
|
+
logger.info("[SkillsManager] Skill updated", {
|
|
169
|
+
skillId: updated.id,
|
|
170
|
+
version: updated.version,
|
|
171
|
+
});
|
|
172
|
+
return updated;
|
|
173
|
+
}
|
|
174
|
+
// delete — soft: mark deprecated so the skill drops out of active
|
|
175
|
+
// listings but stays in storage for audit/undo.
|
|
176
|
+
const existing = await this.get(action.skillId);
|
|
177
|
+
if (!existing) {
|
|
178
|
+
throw new Error(`Skill "${action.skillId}" not found`);
|
|
179
|
+
}
|
|
180
|
+
if (existing.status === "deprecated") {
|
|
181
|
+
throw new Error(`Skill "${existing.name}" is already deleted`);
|
|
182
|
+
}
|
|
183
|
+
const deprecated = {
|
|
184
|
+
...existing,
|
|
185
|
+
status: "deprecated",
|
|
186
|
+
updatedAt: now,
|
|
187
|
+
};
|
|
188
|
+
await this.store.put(deprecated);
|
|
189
|
+
this.invalidateIndex();
|
|
190
|
+
logger.info("[SkillsManager] Skill deprecated", {
|
|
191
|
+
skillId: existing.id,
|
|
192
|
+
name: existing.name,
|
|
193
|
+
});
|
|
194
|
+
return deprecated;
|
|
195
|
+
}
|
|
196
|
+
async assertNameAvailable(name, excludeId) {
|
|
197
|
+
const index = await this.getIndex(true);
|
|
198
|
+
const clash = index.find((item) => item.id !== excludeId &&
|
|
199
|
+
item.name.toLowerCase() === name.toLowerCase() &&
|
|
200
|
+
(item.status ?? "active") === "active");
|
|
201
|
+
if (clash) {
|
|
202
|
+
throw new Error(`A skill named "${name}" already exists`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* A scoped skill without scopeIds can never match anything — reject the
|
|
208
|
+
* write instead of persisting an unmatchable skill. Validated post-merge so
|
|
209
|
+
* both create and patch-style update paths are covered.
|
|
210
|
+
*/
|
|
211
|
+
function assertScopeConsistent(skill) {
|
|
212
|
+
if (skill.scope === "scoped" && (skill.scopeIds ?? []).length === 0) {
|
|
213
|
+
throw new Error('A skill with scope "scoped" must have at least one entry in scopeIds');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Shallow-copy only the defined fields of a patch (undefined must not overwrite). */
|
|
217
|
+
function definedFields(patch) {
|
|
218
|
+
return Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined));
|
|
219
|
+
}
|
package/dist/types/config.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { NeurolinkCredentials } from "./providers.js";
|
|
|
12
12
|
import type { ModelPoolConfig } from "./modelPool.js";
|
|
13
13
|
import type { RequestRouter } from "./requestRouter.js";
|
|
14
14
|
import type { ClassifierRouterConfig } from "./classifierRouter.js";
|
|
15
|
+
import type { SkillsConfig } from "./skills.js";
|
|
15
16
|
/**
|
|
16
17
|
* Main NeuroLink configuration type
|
|
17
18
|
*/
|
|
@@ -114,6 +115,15 @@ export type NeurolinkConstructorConfig = {
|
|
|
114
115
|
* caller pinned both `provider` and `model`. See {@link ClassifierRouterConfig}.
|
|
115
116
|
*/
|
|
116
117
|
classifierRouter?: ClassifierRouterConfig;
|
|
118
|
+
/**
|
|
119
|
+
* Native skills: versioned, discoverable instruction packs (SOPs,
|
|
120
|
+
* playbooks) with progressive disclosure. When enabled, built-in
|
|
121
|
+
* search_skills / list_skills tools are registered (plus gated mutation
|
|
122
|
+
* tools) and a compact skills index is injected into the system prompt
|
|
123
|
+
* of each generate()/stream() call. Opt-in and fails open on read paths.
|
|
124
|
+
* See {@link SkillsConfig}.
|
|
125
|
+
*/
|
|
126
|
+
skills?: SkillsConfig;
|
|
117
127
|
};
|
|
118
128
|
/**
|
|
119
129
|
* Configuration for MCP enhancement modules wired into generate()/stream() paths.
|
package/dist/types/generate.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AIProviderName } from "../constants/enums.js";
|
|
2
2
|
import type { RAGConfig } from "./rag.js";
|
|
3
|
+
import type { SkillsCallOptions } from "./skills.js";
|
|
3
4
|
import type { AnalyticsData, TokenUsage } from "./analytics.js";
|
|
4
5
|
import type { JsonValue } from "./common.js";
|
|
5
6
|
import type { Content, ImageWithAltText } from "./content.js";
|
|
@@ -615,6 +616,13 @@ export type GenerateOptions = {
|
|
|
615
616
|
* @deprecated Use `piiDetection`, `responseValidation`, and `inputValidation` instead.
|
|
616
617
|
*/
|
|
617
618
|
processors?: ProcessorPipelineConfig;
|
|
619
|
+
/**
|
|
620
|
+
* Per-call skills control. Only effective when the instance was
|
|
621
|
+
* constructed with `skills.enabled: true`. Lets a call disable the
|
|
622
|
+
* prompt index, or narrow it by scope/tags. Per-call wins over
|
|
623
|
+
* instance config.
|
|
624
|
+
*/
|
|
625
|
+
skills?: SkillsCallOptions;
|
|
618
626
|
};
|
|
619
627
|
/**
|
|
620
628
|
* Represents an additional user whose memory should be included in a generate/stream call.
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/index.js
CHANGED