@juspay/neurolink 9.85.1 → 9.86.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/browser/neurolink.min.js +370 -362
  3. package/dist/context/contextCompactor.js +16 -2
  4. package/dist/context/stages/slidingWindowTruncator.js +76 -30
  5. package/dist/core/conversationMemoryManager.js +13 -2
  6. package/dist/core/redisConversationMemoryManager.js +10 -1
  7. package/dist/lib/context/contextCompactor.js +16 -2
  8. package/dist/lib/context/stages/slidingWindowTruncator.js +76 -30
  9. package/dist/lib/core/conversationMemoryManager.js +13 -2
  10. package/dist/lib/core/redisConversationMemoryManager.js +10 -1
  11. package/dist/lib/neurolink.d.ts +31 -6
  12. package/dist/lib/neurolink.js +163 -33
  13. package/dist/lib/skills/skillMatcher.d.ts +33 -4
  14. package/dist/lib/skills/skillMatcher.js +81 -6
  15. package/dist/lib/skills/skillSessionTracker.d.ts +52 -0
  16. package/dist/lib/skills/skillSessionTracker.js +150 -0
  17. package/dist/lib/skills/skillStoreRedis.d.ts +8 -0
  18. package/dist/lib/skills/skillStoreRedis.js +18 -2
  19. package/dist/lib/skills/skillStoreS3.d.ts +17 -2
  20. package/dist/lib/skills/skillStoreS3.js +78 -6
  21. package/dist/lib/skills/skillStores.d.ts +16 -2
  22. package/dist/lib/skills/skillStores.js +94 -5
  23. package/dist/lib/skills/skillTools.d.ts +26 -10
  24. package/dist/lib/skills/skillTools.js +190 -79
  25. package/dist/lib/skills/skillsManager.d.ts +25 -1
  26. package/dist/lib/skills/skillsManager.js +46 -4
  27. package/dist/lib/types/config.d.ts +5 -5
  28. package/dist/lib/types/conversation.d.ts +27 -0
  29. package/dist/lib/types/skills.d.ts +145 -14
  30. package/dist/lib/types/skills.js +3 -3
  31. package/dist/lib/utils/conversationMemory.d.ts +1 -1
  32. package/dist/lib/utils/conversationMemory.js +15 -2
  33. package/dist/neurolink.d.ts +31 -6
  34. package/dist/neurolink.js +163 -33
  35. package/dist/skills/skillMatcher.d.ts +33 -4
  36. package/dist/skills/skillMatcher.js +81 -6
  37. package/dist/skills/skillSessionTracker.d.ts +52 -0
  38. package/dist/skills/skillSessionTracker.js +149 -0
  39. package/dist/skills/skillStoreRedis.d.ts +8 -0
  40. package/dist/skills/skillStoreRedis.js +18 -2
  41. package/dist/skills/skillStoreS3.d.ts +17 -2
  42. package/dist/skills/skillStoreS3.js +78 -6
  43. package/dist/skills/skillStores.d.ts +16 -2
  44. package/dist/skills/skillStores.js +94 -5
  45. package/dist/skills/skillTools.d.ts +26 -10
  46. package/dist/skills/skillTools.js +190 -79
  47. package/dist/skills/skillsManager.d.ts +25 -1
  48. package/dist/skills/skillsManager.js +46 -4
  49. package/dist/types/config.d.ts +5 -5
  50. package/dist/types/conversation.d.ts +27 -0
  51. package/dist/types/skills.d.ts +145 -14
  52. package/dist/types/skills.js +3 -3
  53. package/dist/utils/conversationMemory.d.ts +1 -1
  54. package/dist/utils/conversationMemory.js +15 -2
  55. package/package.json +1 -1
@@ -1,126 +1,240 @@
1
1
  /**
2
- * Built-in skill tools, following the createMemoryRetrievalTools /
3
- * createFileTools / createTaskTools factory pattern.
2
+ * Built-in skill tools.
3
+ *
4
+ * Two factories, mirroring the two disclosure levels:
5
+ *
6
+ * createSkillTools — instance tools registered once at construction:
7
+ * list_skills (lightweight catalog) plus the gated skill_create /
8
+ * skill_update / skill_delete mutation tools.
9
+ *
10
+ * createSkillCallTools — per-call tools injected into options.tools by
11
+ * prepareGenerate/prepareStream (the RAG-tool pattern): use_skill, whose
12
+ * description embeds the `<available_skills>` listing so the model
13
+ * decides from context when a skill applies (no mandatory pre-flight
14
+ * call), and read_skill_resource for on-demand auxiliary files. The
15
+ * sessionId rides in by closure, so activations pin to the session
16
+ * without runtime tool-context plumbing.
4
17
  *
5
18
  * The manager is resolved lazily via a resolver callback so tools can be
6
19
  * registered at construction time while the store initializes on first
7
20
  * 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
21
  */
22
+ import { trace } from "@opentelemetry/api";
13
23
  import { z } from "zod";
14
24
  import { logger } from "../utils/logger.js";
15
25
  import { tool } from "../utils/tool.js";
16
- /** Trim a hydrated skill to the fields the model needs. */
17
- function toToolSkill(skill) {
26
+ import { isSkillVisibleInScope } from "./skillMatcher.js";
27
+ const NOT_CONFIGURED = {
28
+ success: false,
29
+ error: "Skills are not available — the skills store failed to initialize. " +
30
+ "Answer from general knowledge.",
31
+ };
32
+ /** Response payload for an activated skill — full instructions, never truncated. */
33
+ function toActivationPayload(skill) {
18
34
  return {
19
- id: skill.id,
20
35
  name: skill.name,
21
36
  ...(skill.displayName ? { displayName: skill.displayName } : {}),
22
- description: skill.description,
23
- instructions: skill.instructions,
24
- tags: skill.tags ?? [],
25
- scope: skill.scope ?? "global",
26
37
  version: skill.version ?? 1,
38
+ instructions: skill.instructions,
39
+ ...(skill.resources && skill.resources.length > 0
40
+ ? {
41
+ resources: skill.resources.map((r) => r.path),
42
+ resourcesHint: "Auxiliary files — read one with read_skill_resource only when the task needs it.",
43
+ }
44
+ : {}),
45
+ note: "Follow these instructions for the current task. The skill stays loaded " +
46
+ "for this conversation — do not invoke use_skill for it again.",
27
47
  };
28
48
  }
49
+ /** Build the use_skill description for the call's discovery mode. */
50
+ function useSkillDescription(context) {
51
+ const base = "Load a team-defined skill (SOP, playbook, workflow) by name to get its full instructions. " +
52
+ "When the user's task matches a skill, invoke this tool, then follow the loaded instructions exactly. " +
53
+ "Do NOT invoke it for a skill that is already loaded in this conversation, and do not " +
54
+ "invoke it at all when no skill matches the task.";
55
+ if (context.discovery === "tool" && context.listing) {
56
+ return `${base}\n\nSkills available in this conversation:\n${context.listing}`;
57
+ }
58
+ if (context.discovery === "system-prompt") {
59
+ return `${base} The available skills are listed under "## Available Skills" in your instructions.`;
60
+ }
61
+ return `${base} Discover available skills with list_skills.`;
62
+ }
29
63
  /**
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.
64
+ * Per-call skill tools: use_skill + read_skill_resource. Injected into
65
+ * options.tools for one generate()/stream() call; same-name entries shadow
66
+ * any registered tool, so the description always reflects this call's
67
+ * listing and scope.
33
68
  */
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: [] },
69
+ export function createSkillCallTools(resolveManager, context) {
70
+ /**
71
+ * Rebuild activation state from stored history before every dedup
72
+ * check. use_skill is rare, so one history read per attempt is cheap
73
+ * and it keeps dedup truthful across restarts, other instances sharing
74
+ * the memory backend, and pins whose persistence failed. Fail-open: a
75
+ * hydration error only risks a duplicate pin, never a lost activation.
76
+ */
77
+ const ensureHydrated = async (manager) => {
78
+ const { sessionId, getStoredMessages } = context;
79
+ if (!sessionId) {
80
+ return;
81
+ }
82
+ try {
83
+ const stored = getStoredMessages
84
+ ? await getStoredMessages(sessionId)
85
+ : [];
86
+ manager.sessions.hydrate(sessionId, stored);
87
+ }
88
+ catch (error) {
89
+ logger.warn("[SkillTools] Session hydration failed — continuing", {
90
+ sessionId,
91
+ error: error instanceof Error ? error.message : String(error),
92
+ });
93
+ }
40
94
  };
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.",
95
+ return {
96
+ use_skill: tool({
97
+ description: useSkillDescription(context),
53
98
  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
99
+ name: z
65
100
  .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."),
101
+ .min(1)
102
+ .describe('Exact skill name from the available skills listing, e.g. "refund_dispute_escalation".'),
69
103
  }),
70
104
  execute: async (args) => {
71
- if (!args.query && !args.tag) {
105
+ const manager = resolveManager();
106
+ if (!manager) {
107
+ return NOT_CONFIGURED;
108
+ }
109
+ try {
110
+ const skill = await manager.get(args.name);
111
+ if (!skill || !isSkillVisibleInScope(skill, context.scopeId)) {
112
+ return {
113
+ success: false,
114
+ error: `No skill named "${args.name}" is available. Use the exact name ` +
115
+ "from the available skills listing (or list_skills).",
116
+ };
117
+ }
118
+ if (context.sessionId && context.sessionPersistence) {
119
+ await ensureHydrated(manager);
120
+ if (manager.sessions.isActive(context.sessionId, skill.id, skill.name)) {
121
+ // Report the version the session actually follows — a
122
+ // mid-session update must not masquerade as loaded.
123
+ const activation = manager.sessions.getActivation(context.sessionId, skill.id, skill.name);
124
+ return {
125
+ success: true,
126
+ data: {
127
+ status: "already_loaded",
128
+ name: skill.name,
129
+ version: activation?.version ?? skill.version ?? 1,
130
+ message: "This skill is already loaded in the conversation — follow " +
131
+ "the instructions from when it was loaded.",
132
+ },
133
+ };
134
+ }
135
+ manager.sessions.recordActivation(context.sessionId, skill);
136
+ }
137
+ const pinned = Boolean(context.sessionId && context.sessionPersistence);
138
+ trace.getActiveSpan()?.setAttributes({
139
+ "skill.name": skill.name,
140
+ "skill.version": skill.version ?? 1,
141
+ "skill.instructions_chars": skill.instructions.length,
142
+ "skill.pinned": pinned,
143
+ });
144
+ logger.debug("[SkillTools] Skill activated", {
145
+ skill: skill.name,
146
+ version: skill.version ?? 1,
147
+ sessionId: context.sessionId,
148
+ pinned,
149
+ });
150
+ return { success: true, data: toActivationPayload(skill) };
151
+ }
152
+ catch (error) {
153
+ logger.warn("[SkillTools] use_skill failed", {
154
+ skill: args.name,
155
+ error: error instanceof Error ? error.message : String(error),
156
+ });
72
157
  return {
73
158
  success: false,
74
- error: 'At least one of "query" or "tag" must be provided to search_skills.',
75
- data: { skills: [] },
159
+ error: error instanceof Error ? error.message : String(error),
76
160
  };
77
161
  }
162
+ },
163
+ }),
164
+ read_skill_resource: tool({
165
+ description: "Read an auxiliary file bundled with a loaded skill (paths appear in the " +
166
+ "skill's `resources` list or are referenced from its instructions). Load the " +
167
+ "skill with use_skill first. Only read files the current task actually needs — " +
168
+ "each resource costs context.",
169
+ inputSchema: z.object({
170
+ skill: z.string().min(1).describe("Name of the loaded skill."),
171
+ path: z
172
+ .string()
173
+ .min(1)
174
+ .describe('Resource path relative to the skill, e.g. "references/forms.md".'),
175
+ }),
176
+ execute: async (args) => {
78
177
  const manager = resolveManager();
79
178
  if (!manager) {
80
- return notConfigured;
179
+ return NOT_CONFIGURED;
81
180
  }
82
181
  try {
83
- const skills = await manager.search(args);
84
- if (skills.length === 0) {
182
+ const skill = await manager.get(args.skill);
183
+ if (!skill || !isSkillVisibleInScope(skill, context.scopeId)) {
85
184
  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
- },
185
+ success: false,
186
+ error: `No skill named "${args.skill}" is available.`,
187
+ };
188
+ }
189
+ if (context.sessionId && context.sessionPersistence) {
190
+ await ensureHydrated(manager);
191
+ if (!manager.sessions.isActive(context.sessionId, skill.id, skill.name)) {
192
+ return {
193
+ success: false,
194
+ error: `Skill "${skill.name}" is not loaded — call use_skill first.`,
195
+ };
196
+ }
197
+ }
198
+ const content = await manager.getResource(skill.id, args.path);
199
+ if (content === null) {
200
+ return {
201
+ success: false,
202
+ error: `Resource "${args.path}" not found on skill "${skill.name}". ` +
203
+ "Valid paths are listed in the skill's `resources`.",
92
204
  };
93
205
  }
94
206
  return {
95
207
  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
- },
208
+ data: { skill: skill.name, path: args.path, content },
105
209
  };
106
210
  }
107
211
  catch (error) {
108
- logger.warn("[SkillTools] search_skills failed", {
212
+ logger.warn("[SkillTools] read_skill_resource failed", {
213
+ skill: args.skill,
214
+ path: args.path,
109
215
  error: error instanceof Error ? error.message : String(error),
110
216
  });
111
217
  return {
112
218
  success: false,
113
219
  error: error instanceof Error ? error.message : String(error),
114
- data: { skills: [] },
115
220
  };
116
221
  }
117
222
  },
118
223
  }),
224
+ };
225
+ }
226
+ /**
227
+ * Instance skill tools bound to a lazily-resolved manager: list_skills
228
+ * plus the gated mutation tools. Returns Vercel AI SDK tool() objects
229
+ * (description + Zod inputSchema + execute) keyed by tool name.
230
+ */
231
+ export function createSkillTools(resolveManager, options) {
232
+ const tools = {
119
233
  list_skills: tool({
120
234
  description: "Returns a lightweight list of all available skills — name, display name, description, and " +
121
235
  "tags only. No instructions are returned, keeping context cost minimal. " +
122
236
  '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.',
237
+ 'me with?". To load a skill for a task, use use_skill instead.',
124
238
  inputSchema: z.object({
125
239
  scopeId: z
126
240
  .string()
@@ -130,7 +244,7 @@ export function createSkillTools(resolveManager, options) {
130
244
  execute: async (args) => {
131
245
  const manager = resolveManager();
132
246
  if (!manager) {
133
- return notConfigured;
247
+ return { ...NOT_CONFIGURED, data: { skills: [] } };
134
248
  }
135
249
  try {
136
250
  const items = await manager.list(args.scopeId);
@@ -170,10 +284,7 @@ export function createSkillTools(resolveManager, options) {
170
284
  async function runMutation(resolveManager, action) {
171
285
  const manager = resolveManager();
172
286
  if (!manager) {
173
- return {
174
- success: false,
175
- error: "Skills are not available — the skills store failed to initialize.",
176
- };
287
+ return NOT_CONFIGURED;
177
288
  }
178
289
  try {
179
290
  const result = await manager.requestMutation(action);
@@ -285,8 +396,8 @@ function createSkillMutationTools(resolveManager) {
285
396
  skill_update: tool({
286
397
  description: "Propose an update to an EXISTING skill. Only the provided fields change; the version is " +
287
398
  "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.",
399
+ "human approval. Look the skill up with list_skills first to get its exact name. " +
400
+ "Do not paraphrase or expand the user's instructions.",
290
401
  inputSchema: z.object({
291
402
  skillId: z
292
403
  .string()
@@ -7,6 +7,7 @@
7
7
  * write paths fail closed (errors reject the mutation).
8
8
  */
9
9
  import type { SkillDefinition, SkillIndexItem, SkillMutationAction, SkillMutationResult, SkillSearchQuery, SkillsConfig } from "../types/index.js";
10
+ import { SkillSessionTracker } from "./skillSessionTracker.js";
10
11
  export declare class SkillsManager {
11
12
  private readonly config;
12
13
  private readonly store;
@@ -14,8 +15,14 @@ export declare class SkillsManager {
14
15
  private cachedIndexAt;
15
16
  /** Serializes writes within this process — see requestMutation(). */
16
17
  private mutationQueue;
18
+ /** Per-session activation state (pinned skills). */
19
+ readonly sessions: SkillSessionTracker;
17
20
  constructor(config: SkillsConfig);
18
- /** Cached index read. TTL 0 disables caching. */
21
+ /**
22
+ * Cached index read, sorted by name. TTL 0 disables caching. Sorting
23
+ * here (not per render) keeps every downstream listing byte-stable
24
+ * regardless of store enumeration order.
25
+ */
19
26
  getIndex(forceRefresh?: boolean): Promise<SkillIndexItem[]>;
20
27
  private invalidateIndex;
21
28
  /**
@@ -36,6 +43,23 @@ export declare class SkillsManager {
36
43
  scopeId?: string;
37
44
  tags?: string[];
38
45
  }): Promise<string | null>;
46
+ /**
47
+ * Render the `<available_skills>` block for the use_skill tool
48
+ * description ("tool" discovery mode), or null when nothing is visible.
49
+ * Bounded by listingBudgetChars; entries are never dropped.
50
+ */
51
+ buildToolListing(options?: {
52
+ scopeId?: string;
53
+ tags?: string[];
54
+ }): Promise<string | null>;
55
+ /** Visible (active, scope- and tag-filtered) index entries for one call. */
56
+ private visibleItems;
57
+ /**
58
+ * Read an auxiliary resource file bundled with a skill. Paths are
59
+ * relative to the skill; traversal segments are rejected. Null when the
60
+ * skill, the resource, or store resource support is absent.
61
+ */
62
+ getResource(idOrName: string, resourcePath: string): Promise<string | null>;
39
63
  /**
40
64
  * Whether skill create/update/delete is enabled on this instance. Gates the
41
65
  * LLM-facing `skill_*` tools (registration) and the server REST mutation
@@ -8,11 +8,13 @@
8
8
  */
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { logger } from "../utils/logger.js";
11
- import { filterSkillIndex, formatSkillsPromptIndex } from "./skillMatcher.js";
11
+ import { filterSkillIndex, formatSkillsPromptIndex, renderSkillListing, sortSkillIndex, } from "./skillMatcher.js";
12
+ import { SkillSessionTracker } from "./skillSessionTracker.js";
12
13
  import { createSkillStore } from "./skillStores.js";
13
14
  const DEFAULT_MAX_MATCHES = 5;
14
15
  const DEFAULT_PROMPT_INDEX_MAX_ITEMS = 50;
15
16
  const DEFAULT_INDEX_CACHE_TTL_MS = 30_000;
17
+ const DEFAULT_LISTING_BUDGET_CHARS = 15_000;
16
18
  export class SkillsManager {
17
19
  config;
18
20
  store;
@@ -20,11 +22,17 @@ export class SkillsManager {
20
22
  cachedIndexAt = 0;
21
23
  /** Serializes writes within this process — see requestMutation(). */
22
24
  mutationQueue = Promise.resolve();
25
+ /** Per-session activation state (pinned skills). */
26
+ sessions = new SkillSessionTracker();
23
27
  constructor(config) {
24
28
  this.config = config;
25
29
  this.store = createSkillStore(config.storage);
26
30
  }
27
- /** Cached index read. TTL 0 disables caching. */
31
+ /**
32
+ * Cached index read, sorted by name. TTL 0 disables caching. Sorting
33
+ * here (not per render) keeps every downstream listing byte-stable
34
+ * regardless of store enumeration order.
35
+ */
28
36
  async getIndex(forceRefresh = false) {
29
37
  const ttl = this.config.indexCacheTtlMs ?? DEFAULT_INDEX_CACHE_TTL_MS;
30
38
  const fresh = this.cachedIndex !== null &&
@@ -33,7 +41,7 @@ export class SkillsManager {
33
41
  if (fresh && !forceRefresh && this.cachedIndex) {
34
42
  return this.cachedIndex;
35
43
  }
36
- const index = await this.store.index();
44
+ const index = sortSkillIndex(await this.store.index());
37
45
  this.cachedIndex = index;
38
46
  this.cachedIndexAt = Date.now();
39
47
  return index;
@@ -87,12 +95,46 @@ export class SkillsManager {
87
95
  * nothing is visible. Never includes instructions.
88
96
  */
89
97
  async buildPromptIndex(options) {
98
+ const items = await this.visibleItems(options);
99
+ return formatSkillsPromptIndex(items, this.config.promptIndexMaxItems ?? DEFAULT_PROMPT_INDEX_MAX_ITEMS);
100
+ }
101
+ /**
102
+ * Render the `<available_skills>` block for the use_skill tool
103
+ * description ("tool" discovery mode), or null when nothing is visible.
104
+ * Bounded by listingBudgetChars; entries are never dropped.
105
+ */
106
+ async buildToolListing(options) {
107
+ const items = await this.visibleItems(options);
108
+ return renderSkillListing(items, this.config.listingBudgetChars ?? DEFAULT_LISTING_BUDGET_CHARS);
109
+ }
110
+ /** Visible (active, scope- and tag-filtered) index entries for one call. */
111
+ async visibleItems(options) {
90
112
  let items = await this.list(options?.scopeId);
91
113
  if (options?.tags && options.tags.length > 0) {
92
114
  const wanted = options.tags.map((t) => t.toLowerCase());
93
115
  items = items.filter((item) => (item.tags ?? []).some((t) => wanted.includes(t.toLowerCase())));
94
116
  }
95
- return formatSkillsPromptIndex(items, this.config.promptIndexMaxItems ?? DEFAULT_PROMPT_INDEX_MAX_ITEMS);
117
+ return items;
118
+ }
119
+ /**
120
+ * Read an auxiliary resource file bundled with a skill. Paths are
121
+ * relative to the skill; traversal segments are rejected. Null when the
122
+ * skill, the resource, or store resource support is absent.
123
+ */
124
+ async getResource(idOrName, resourcePath) {
125
+ const normalized = resourcePath.replace(/\\/g, "/");
126
+ if (normalized.startsWith("/") ||
127
+ normalized.split("/").some((segment) => segment === "..")) {
128
+ throw new Error(`Invalid resource path "${resourcePath}" — must be relative to the skill directory`);
129
+ }
130
+ if (!this.store.getResource) {
131
+ return null;
132
+ }
133
+ const skill = await this.get(idOrName);
134
+ if (!skill) {
135
+ return null;
136
+ }
137
+ return this.store.getResource(skill.id, normalized);
96
138
  }
97
139
  /**
98
140
  * Whether skill create/update/delete is enabled on this instance. Gates the
@@ -117,11 +117,11 @@ export type NeurolinkConstructorConfig = {
117
117
  classifierRouter?: ClassifierRouterConfig;
118
118
  /**
119
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}.
120
+ * playbooks) with progressive disclosure. When enabled, each
121
+ * generate()/stream() call gets a skills discovery listing plus
122
+ * use_skill / read_skill_resource tools; activated skill instructions
123
+ * pin to the session so they are loaded once and replayed from history.
124
+ * Opt-in and fails open on read paths. See {@link SkillsConfig}.
125
125
  */
126
126
  skills?: SkillsConfig;
127
127
  };
@@ -250,6 +250,20 @@ export type ChatMessageMetadata = {
250
250
  * payload from the local artifact store.
251
251
  */
252
252
  artifactId?: string;
253
+ /**
254
+ * Marks a pinned skill-activation message: the full instructions of a
255
+ * skill loaded via use_skill, persisted into session history so later
256
+ * turns replay it verbatim instead of re-fetching the skill. Pinned
257
+ * skill messages are protected from sliding-window truncation and are
258
+ * re-included after memory summarization.
259
+ */
260
+ isSkill?: boolean;
261
+ /** Skill id of a pinned skill-activation message. */
262
+ skillId?: string;
263
+ /** Skill name of a pinned skill-activation message. */
264
+ skillName?: string;
265
+ /** Skill version captured at activation (sessions pin the activated version). */
266
+ skillVersion?: number;
253
267
  };
254
268
  /**
255
269
  * Chat message format for conversation history
@@ -383,6 +397,19 @@ export type StoreConversationTurnOptions = {
383
397
  };
384
398
  /** Gemini 3 thought signature for reasoning continuity across turns */
385
399
  thoughtSignature?: string;
400
+ /**
401
+ * Pinned skill-activation messages (skills v2) recorded during this turn.
402
+ * Inserted between the user and assistant messages so replayed history
403
+ * mirrors the actual order: ask → skill loaded → answer. Stored verbatim —
404
+ * skill instructions are never truncated.
405
+ *
406
+ * Invariant for history consumers: a skill-bearing turn is a
407
+ * user → skill(user-role, metadata.isSkill) → assistant triplet, so
408
+ * stored history is NOT strictly pair-wise alternating. Pair-based
409
+ * logic must filter `metadata.isSkill` first (see slidingWindowTruncator
410
+ * for the canonical partition-and-reanchor pattern).
411
+ */
412
+ skillMessages?: ChatMessage[];
386
413
  };
387
414
  /**
388
415
  * Lightweight session metadata for efficient session listing