@juspay/neurolink 9.85.1 → 9.86.1

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 (63) hide show
  1. package/CHANGELOG.md +12 -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/proxy/accountQuota.d.ts +5 -0
  14. package/dist/lib/proxy/accountQuota.js +8 -1
  15. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +22 -6
  16. package/dist/lib/server/routes/claudeProxyRoutes.js +52 -8
  17. package/dist/lib/skills/skillMatcher.d.ts +33 -4
  18. package/dist/lib/skills/skillMatcher.js +81 -6
  19. package/dist/lib/skills/skillSessionTracker.d.ts +52 -0
  20. package/dist/lib/skills/skillSessionTracker.js +150 -0
  21. package/dist/lib/skills/skillStoreRedis.d.ts +8 -0
  22. package/dist/lib/skills/skillStoreRedis.js +18 -2
  23. package/dist/lib/skills/skillStoreS3.d.ts +17 -2
  24. package/dist/lib/skills/skillStoreS3.js +78 -6
  25. package/dist/lib/skills/skillStores.d.ts +16 -2
  26. package/dist/lib/skills/skillStores.js +94 -5
  27. package/dist/lib/skills/skillTools.d.ts +26 -10
  28. package/dist/lib/skills/skillTools.js +190 -79
  29. package/dist/lib/skills/skillsManager.d.ts +25 -1
  30. package/dist/lib/skills/skillsManager.js +46 -4
  31. package/dist/lib/types/config.d.ts +5 -5
  32. package/dist/lib/types/conversation.d.ts +27 -0
  33. package/dist/lib/types/skills.d.ts +145 -14
  34. package/dist/lib/types/skills.js +3 -3
  35. package/dist/lib/utils/conversationMemory.d.ts +1 -1
  36. package/dist/lib/utils/conversationMemory.js +15 -2
  37. package/dist/neurolink.d.ts +31 -6
  38. package/dist/neurolink.js +163 -33
  39. package/dist/proxy/accountQuota.d.ts +5 -0
  40. package/dist/proxy/accountQuota.js +8 -1
  41. package/dist/server/routes/claudeProxyRoutes.d.ts +22 -6
  42. package/dist/server/routes/claudeProxyRoutes.js +52 -8
  43. package/dist/skills/skillMatcher.d.ts +33 -4
  44. package/dist/skills/skillMatcher.js +81 -6
  45. package/dist/skills/skillSessionTracker.d.ts +52 -0
  46. package/dist/skills/skillSessionTracker.js +149 -0
  47. package/dist/skills/skillStoreRedis.d.ts +8 -0
  48. package/dist/skills/skillStoreRedis.js +18 -2
  49. package/dist/skills/skillStoreS3.d.ts +17 -2
  50. package/dist/skills/skillStoreS3.js +78 -6
  51. package/dist/skills/skillStores.d.ts +16 -2
  52. package/dist/skills/skillStores.js +94 -5
  53. package/dist/skills/skillTools.d.ts +26 -10
  54. package/dist/skills/skillTools.js +190 -79
  55. package/dist/skills/skillsManager.d.ts +25 -1
  56. package/dist/skills/skillsManager.js +46 -4
  57. package/dist/types/config.d.ts +5 -5
  58. package/dist/types/conversation.d.ts +27 -0
  59. package/dist/types/skills.d.ts +145 -14
  60. package/dist/types/skills.js +3 -3
  61. package/dist/utils/conversationMemory.d.ts +1 -1
  62. package/dist/utils/conversationMemory.js +15 -2
  63. package/package.json +1 -1
@@ -26,7 +26,7 @@ const DEFAULT_CONFIG = {
26
26
  enableTruncate: true,
27
27
  pruneProtectTokens: 40_000,
28
28
  pruneMinimumSavings: 500,
29
- pruneProtectedTools: ["skill"],
29
+ pruneProtectedTools: ["skill", "use_skill", "read_skill_resource"],
30
30
  summarizationProvider: "vertex",
31
31
  summarizationModel: "gemini-2.5-flash",
32
32
  keepRecentRatio: 0.3,
@@ -120,7 +120,21 @@ export class ContextCompactor {
120
120
  targetTokens,
121
121
  }), 120_000, "LLM summarization timed out after 120s");
122
122
  if (summarizeResult.summarized) {
123
- currentMessages = summarizeResult.messages;
123
+ // Pinned skill instructions must survive summarization:
124
+ // re-seat any that the summarized region swallowed right
125
+ // after the summary message (index 0 of the result).
126
+ const survivorIds = new Set(summarizeResult.messages.map((m) => m.id));
127
+ const swallowedSkills = currentMessages.filter((m) => m.metadata?.isSkill && !survivorIds.has(m.id));
128
+ currentMessages =
129
+ swallowedSkills.length > 0
130
+ ? summarizeResult.messages.length > 0
131
+ ? [
132
+ summarizeResult.messages[0],
133
+ ...swallowedSkills,
134
+ ...summarizeResult.messages.slice(1),
135
+ ]
136
+ : swallowedSkills
137
+ : summarizeResult.messages;
124
138
  stagesUsed.push("summarize");
125
139
  }
126
140
  const stageTokensAfter = estimateMessagesTokens(currentMessages, provider);
@@ -58,8 +58,10 @@ function truncateSmallConversation(messages, config) {
58
58
  let totalSaved = 0;
59
59
  for (let i = 0; i < result.length; i++) {
60
60
  const msg = result[i];
61
- // Don't truncate system/summary messages
62
- if (msg.role === "system" || msg.metadata?.isSummary) {
61
+ // Don't truncate system/summary messages or pinned skill instructions
62
+ if (msg.role === "system" ||
63
+ msg.metadata?.isSummary ||
64
+ msg.metadata?.isSkill) {
63
65
  continue;
64
66
  }
65
67
  const proportionalBudget = Math.floor((msgTokens[i] / totalContentTokens) * contentBudget);
@@ -93,8 +95,40 @@ function truncateSmallConversation(messages, config) {
93
95
  return { truncated: false, messages, messagesRemoved: 0 };
94
96
  }
95
97
  export function truncateWithSlidingWindow(messages, config) {
96
- if (messages.length <= 4) {
97
- // Delegate to content truncation for small conversations (BUG-005)
98
+ // Partition pinned skill messages out so the pair-based arithmetic below
99
+ // runs on the alternating user/assistant stream (skill pins are extra
100
+ // user-role messages inside a turn and would misalign every even-count
101
+ // cut). Each skill anchors to the next conversational message; kept
102
+ // anchors put their skills back in place, dropped anchors re-seat them
103
+ // after the truncation marker.
104
+ const skillsByAnchor = new Map();
105
+ const trailingSkills = [];
106
+ const conversational = [];
107
+ for (let i = 0; i < messages.length; i++) {
108
+ const msg = messages[i];
109
+ if (!msg.metadata?.isSkill) {
110
+ conversational.push(msg);
111
+ continue;
112
+ }
113
+ const anchor = messages
114
+ .slice(i + 1)
115
+ .find((next) => !next.metadata?.isSkill);
116
+ if (anchor) {
117
+ const anchored = skillsByAnchor.get(anchor.id);
118
+ if (anchored) {
119
+ anchored.push(msg);
120
+ }
121
+ else {
122
+ skillsByAnchor.set(anchor.id, [msg]);
123
+ }
124
+ }
125
+ else {
126
+ trailingSkills.push(msg);
127
+ }
128
+ }
129
+ if (conversational.length <= 4) {
130
+ // Delegate to content truncation for small conversations (BUG-005);
131
+ // it never removes messages, so skills keep their positions.
98
132
  return truncateSmallConversation(messages, config);
99
133
  }
100
134
  // ADAPTIVE MODE: calculate fraction from actual overage (PERF-001)
@@ -118,8 +152,42 @@ export function truncateWithSlidingWindow(messages, config) {
118
152
  fraction = config?.fraction ?? 0.5;
119
153
  }
120
154
  // Always preserve first user-assistant pair
121
- const firstPair = messages.slice(0, 2);
122
- const remainingMessages = messages.slice(2);
155
+ const firstPair = conversational.slice(0, 2);
156
+ const remainingMessages = conversational.slice(2);
157
+ /** Reassemble a candidate: skills inline before kept anchors, orphans after the marker. */
158
+ const buildCandidate = (keptAfterTruncation, marker) => {
159
+ const keptIds = new Set([...firstPair, ...keptAfterTruncation].map((m) => m.id));
160
+ const orphanedSkills = [];
161
+ for (const [anchorId, anchored] of skillsByAnchor) {
162
+ if (!keptIds.has(anchorId)) {
163
+ orphanedSkills.push(...anchored);
164
+ }
165
+ }
166
+ const withInlineSkills = (msg) => {
167
+ const anchored = skillsByAnchor.get(msg.id);
168
+ return anchored ? [...anchored, msg] : [msg];
169
+ };
170
+ return [
171
+ ...firstPair.flatMap(withInlineSkills),
172
+ marker,
173
+ ...orphanedSkills,
174
+ ...keptAfterTruncation.flatMap(withInlineSkills),
175
+ ...trailingSkills,
176
+ ];
177
+ };
178
+ // Insert a truncation marker with machine-readable metadata so
179
+ // effectiveHistory.ts can detect it via isTruncationMarker /
180
+ // truncationId and removeTruncationTags can rewind it.
181
+ const makeMarker = () => {
182
+ const truncId = randomUUID();
183
+ return {
184
+ id: `truncation-marker-${truncId}`,
185
+ role: "user",
186
+ content: TRUNCATION_MARKER_CONTENT,
187
+ isTruncationMarker: true,
188
+ truncationId: truncId,
189
+ };
190
+ };
123
191
  // ITERATIVE: if first pass isn't enough, increase fraction
124
192
  const maxIterations = config?.maxIterations ?? 3;
125
193
  let currentFraction = fraction;
@@ -129,19 +197,7 @@ export function truncateWithSlidingWindow(messages, config) {
129
197
  if (evenRemoveCount <= 0) {
130
198
  break;
131
199
  }
132
- const keptAfterTruncation = remainingMessages.slice(evenRemoveCount);
133
- // Insert a truncation marker with machine-readable metadata so
134
- // effectiveHistory.ts can detect it via isTruncationMarker /
135
- // truncationId and removeTruncationTags can rewind it.
136
- const truncId = randomUUID();
137
- const marker = {
138
- id: `truncation-marker-${truncId}`,
139
- role: "user",
140
- content: TRUNCATION_MARKER_CONTENT,
141
- isTruncationMarker: true,
142
- truncationId: truncId,
143
- };
144
- const candidateMessages = [...firstPair, marker, ...keptAfterTruncation];
200
+ const candidateMessages = buildCandidate(remainingMessages.slice(evenRemoveCount), makeMarker());
145
201
  validateRoleAlternation(candidateMessages);
146
202
  // If we have token targets, verify the result fits
147
203
  if (config?.targetTokens) {
@@ -168,17 +224,7 @@ export function truncateWithSlidingWindow(messages, config) {
168
224
  const maxRemove = Math.floor(remainingMessages.length * 0.95);
169
225
  const evenMaxRemove = maxRemove - (maxRemove % 2);
170
226
  if (evenMaxRemove > 0) {
171
- const keptMessages = remainingMessages.slice(evenMaxRemove);
172
- // Insert a truncation marker (see iterative block above)
173
- const fallbackTruncId = randomUUID();
174
- const fallbackMarker = {
175
- id: `truncation-marker-${fallbackTruncId}`,
176
- role: "user",
177
- content: TRUNCATION_MARKER_CONTENT,
178
- isTruncationMarker: true,
179
- truncationId: fallbackTruncId,
180
- };
181
- const fallbackMessages = [...firstPair, fallbackMarker, ...keptMessages];
227
+ const fallbackMessages = buildCandidate(remainingMessages.slice(evenMaxRemove), makeMarker());
182
228
  validateRoleAlternation(fallbackMessages);
183
229
  return {
184
230
  truncated: true,
@@ -89,7 +89,14 @@ export class ConversationMemoryManager {
89
89
  if (options.events && options.events.length > 0) {
90
90
  assistantMsg.events = options.events;
91
91
  }
92
- session.messages.push(userMsg, assistantMsg);
92
+ session.messages.push(userMsg);
93
+ // Pinned skill activations ride between ask and answer, mirroring
94
+ // the actual order (ask → skill loaded → answer). Stored verbatim —
95
+ // skill instructions are never truncated.
96
+ if (options.skillMessages && options.skillMessages.length > 0) {
97
+ session.messages.push(...options.skillMessages);
98
+ }
99
+ session.messages.push(assistantMsg);
93
100
  session.lastActivity = Date.now();
94
101
  // Store API-reported token counts if available
95
102
  if (options.tokenUsage) {
@@ -275,7 +282,11 @@ export class ConversationMemoryManager {
275
282
  async getStats() {
276
283
  await this.ensureInitialized();
277
284
  const sessions = Array.from(this.sessions.values());
278
- const totalTurns = sessions.reduce((sum, session) => sum + session.messages.length / MESSAGES_PER_TURN, 0);
285
+ // Pinned skill messages are extra rows inside a turn exclude them so
286
+ // a skill-activating turn still counts as one turn.
287
+ const totalTurns = sessions.reduce((sum, session) => sum +
288
+ session.messages.filter((msg) => !msg.metadata?.isSkill).length /
289
+ MESSAGES_PER_TURN, 0);
279
290
  return {
280
291
  totalSessions: sessions.length,
281
292
  totalTurns,
@@ -457,6 +457,12 @@ export class RedisConversationMemoryManager {
457
457
  };
458
458
  conversation.messages.push(userMsg);
459
459
  await this.flushPendingToolData(conversation, options.sessionId, normalizedUserId);
460
+ // Pinned skill activations ride between ask and answer, mirroring
461
+ // the actual order (ask → skill loaded → answer). Stored verbatim —
462
+ // skill instructions are never truncated.
463
+ if (options.skillMessages && options.skillMessages.length > 0) {
464
+ conversation.messages.push(...options.skillMessages);
465
+ }
460
466
  const assistantMsg = {
461
467
  id: randomUUID(),
462
468
  timestamp: this.generateTimestamp(),
@@ -1097,7 +1103,10 @@ User message: "${userMessage}"`;
1097
1103
  const conversationData = await this.redisClient.get(key);
1098
1104
  const conversation = deserializeConversation(conversationData);
1099
1105
  if (conversation?.messages) {
1100
- totalTurns += conversation.messages.length / MESSAGES_PER_TURN;
1106
+ // Pinned skill messages are extra rows inside a turn — exclude them
1107
+ // so a skill-activating turn still counts as one turn.
1108
+ totalTurns +=
1109
+ conversation.messages.filter((msg) => !msg.metadata?.isSkill).length / MESSAGES_PER_TURN;
1101
1110
  }
1102
1111
  }
1103
1112
  return {
@@ -26,7 +26,7 @@ const DEFAULT_CONFIG = {
26
26
  enableTruncate: true,
27
27
  pruneProtectTokens: 40_000,
28
28
  pruneMinimumSavings: 500,
29
- pruneProtectedTools: ["skill"],
29
+ pruneProtectedTools: ["skill", "use_skill", "read_skill_resource"],
30
30
  summarizationProvider: "vertex",
31
31
  summarizationModel: "gemini-2.5-flash",
32
32
  keepRecentRatio: 0.3,
@@ -120,7 +120,21 @@ export class ContextCompactor {
120
120
  targetTokens,
121
121
  }), 120_000, "LLM summarization timed out after 120s");
122
122
  if (summarizeResult.summarized) {
123
- currentMessages = summarizeResult.messages;
123
+ // Pinned skill instructions must survive summarization:
124
+ // re-seat any that the summarized region swallowed right
125
+ // after the summary message (index 0 of the result).
126
+ const survivorIds = new Set(summarizeResult.messages.map((m) => m.id));
127
+ const swallowedSkills = currentMessages.filter((m) => m.metadata?.isSkill && !survivorIds.has(m.id));
128
+ currentMessages =
129
+ swallowedSkills.length > 0
130
+ ? summarizeResult.messages.length > 0
131
+ ? [
132
+ summarizeResult.messages[0],
133
+ ...swallowedSkills,
134
+ ...summarizeResult.messages.slice(1),
135
+ ]
136
+ : swallowedSkills
137
+ : summarizeResult.messages;
124
138
  stagesUsed.push("summarize");
125
139
  }
126
140
  const stageTokensAfter = estimateMessagesTokens(currentMessages, provider);
@@ -58,8 +58,10 @@ function truncateSmallConversation(messages, config) {
58
58
  let totalSaved = 0;
59
59
  for (let i = 0; i < result.length; i++) {
60
60
  const msg = result[i];
61
- // Don't truncate system/summary messages
62
- if (msg.role === "system" || msg.metadata?.isSummary) {
61
+ // Don't truncate system/summary messages or pinned skill instructions
62
+ if (msg.role === "system" ||
63
+ msg.metadata?.isSummary ||
64
+ msg.metadata?.isSkill) {
63
65
  continue;
64
66
  }
65
67
  const proportionalBudget = Math.floor((msgTokens[i] / totalContentTokens) * contentBudget);
@@ -93,8 +95,40 @@ function truncateSmallConversation(messages, config) {
93
95
  return { truncated: false, messages, messagesRemoved: 0 };
94
96
  }
95
97
  export function truncateWithSlidingWindow(messages, config) {
96
- if (messages.length <= 4) {
97
- // Delegate to content truncation for small conversations (BUG-005)
98
+ // Partition pinned skill messages out so the pair-based arithmetic below
99
+ // runs on the alternating user/assistant stream (skill pins are extra
100
+ // user-role messages inside a turn and would misalign every even-count
101
+ // cut). Each skill anchors to the next conversational message; kept
102
+ // anchors put their skills back in place, dropped anchors re-seat them
103
+ // after the truncation marker.
104
+ const skillsByAnchor = new Map();
105
+ const trailingSkills = [];
106
+ const conversational = [];
107
+ for (let i = 0; i < messages.length; i++) {
108
+ const msg = messages[i];
109
+ if (!msg.metadata?.isSkill) {
110
+ conversational.push(msg);
111
+ continue;
112
+ }
113
+ const anchor = messages
114
+ .slice(i + 1)
115
+ .find((next) => !next.metadata?.isSkill);
116
+ if (anchor) {
117
+ const anchored = skillsByAnchor.get(anchor.id);
118
+ if (anchored) {
119
+ anchored.push(msg);
120
+ }
121
+ else {
122
+ skillsByAnchor.set(anchor.id, [msg]);
123
+ }
124
+ }
125
+ else {
126
+ trailingSkills.push(msg);
127
+ }
128
+ }
129
+ if (conversational.length <= 4) {
130
+ // Delegate to content truncation for small conversations (BUG-005);
131
+ // it never removes messages, so skills keep their positions.
98
132
  return truncateSmallConversation(messages, config);
99
133
  }
100
134
  // ADAPTIVE MODE: calculate fraction from actual overage (PERF-001)
@@ -118,8 +152,42 @@ export function truncateWithSlidingWindow(messages, config) {
118
152
  fraction = config?.fraction ?? 0.5;
119
153
  }
120
154
  // Always preserve first user-assistant pair
121
- const firstPair = messages.slice(0, 2);
122
- const remainingMessages = messages.slice(2);
155
+ const firstPair = conversational.slice(0, 2);
156
+ const remainingMessages = conversational.slice(2);
157
+ /** Reassemble a candidate: skills inline before kept anchors, orphans after the marker. */
158
+ const buildCandidate = (keptAfterTruncation, marker) => {
159
+ const keptIds = new Set([...firstPair, ...keptAfterTruncation].map((m) => m.id));
160
+ const orphanedSkills = [];
161
+ for (const [anchorId, anchored] of skillsByAnchor) {
162
+ if (!keptIds.has(anchorId)) {
163
+ orphanedSkills.push(...anchored);
164
+ }
165
+ }
166
+ const withInlineSkills = (msg) => {
167
+ const anchored = skillsByAnchor.get(msg.id);
168
+ return anchored ? [...anchored, msg] : [msg];
169
+ };
170
+ return [
171
+ ...firstPair.flatMap(withInlineSkills),
172
+ marker,
173
+ ...orphanedSkills,
174
+ ...keptAfterTruncation.flatMap(withInlineSkills),
175
+ ...trailingSkills,
176
+ ];
177
+ };
178
+ // Insert a truncation marker with machine-readable metadata so
179
+ // effectiveHistory.ts can detect it via isTruncationMarker /
180
+ // truncationId and removeTruncationTags can rewind it.
181
+ const makeMarker = () => {
182
+ const truncId = randomUUID();
183
+ return {
184
+ id: `truncation-marker-${truncId}`,
185
+ role: "user",
186
+ content: TRUNCATION_MARKER_CONTENT,
187
+ isTruncationMarker: true,
188
+ truncationId: truncId,
189
+ };
190
+ };
123
191
  // ITERATIVE: if first pass isn't enough, increase fraction
124
192
  const maxIterations = config?.maxIterations ?? 3;
125
193
  let currentFraction = fraction;
@@ -129,19 +197,7 @@ export function truncateWithSlidingWindow(messages, config) {
129
197
  if (evenRemoveCount <= 0) {
130
198
  break;
131
199
  }
132
- const keptAfterTruncation = remainingMessages.slice(evenRemoveCount);
133
- // Insert a truncation marker with machine-readable metadata so
134
- // effectiveHistory.ts can detect it via isTruncationMarker /
135
- // truncationId and removeTruncationTags can rewind it.
136
- const truncId = randomUUID();
137
- const marker = {
138
- id: `truncation-marker-${truncId}`,
139
- role: "user",
140
- content: TRUNCATION_MARKER_CONTENT,
141
- isTruncationMarker: true,
142
- truncationId: truncId,
143
- };
144
- const candidateMessages = [...firstPair, marker, ...keptAfterTruncation];
200
+ const candidateMessages = buildCandidate(remainingMessages.slice(evenRemoveCount), makeMarker());
145
201
  validateRoleAlternation(candidateMessages);
146
202
  // If we have token targets, verify the result fits
147
203
  if (config?.targetTokens) {
@@ -168,17 +224,7 @@ export function truncateWithSlidingWindow(messages, config) {
168
224
  const maxRemove = Math.floor(remainingMessages.length * 0.95);
169
225
  const evenMaxRemove = maxRemove - (maxRemove % 2);
170
226
  if (evenMaxRemove > 0) {
171
- const keptMessages = remainingMessages.slice(evenMaxRemove);
172
- // Insert a truncation marker (see iterative block above)
173
- const fallbackTruncId = randomUUID();
174
- const fallbackMarker = {
175
- id: `truncation-marker-${fallbackTruncId}`,
176
- role: "user",
177
- content: TRUNCATION_MARKER_CONTENT,
178
- isTruncationMarker: true,
179
- truncationId: fallbackTruncId,
180
- };
181
- const fallbackMessages = [...firstPair, fallbackMarker, ...keptMessages];
227
+ const fallbackMessages = buildCandidate(remainingMessages.slice(evenMaxRemove), makeMarker());
182
228
  validateRoleAlternation(fallbackMessages);
183
229
  return {
184
230
  truncated: true,
@@ -89,7 +89,14 @@ export class ConversationMemoryManager {
89
89
  if (options.events && options.events.length > 0) {
90
90
  assistantMsg.events = options.events;
91
91
  }
92
- session.messages.push(userMsg, assistantMsg);
92
+ session.messages.push(userMsg);
93
+ // Pinned skill activations ride between ask and answer, mirroring
94
+ // the actual order (ask → skill loaded → answer). Stored verbatim —
95
+ // skill instructions are never truncated.
96
+ if (options.skillMessages && options.skillMessages.length > 0) {
97
+ session.messages.push(...options.skillMessages);
98
+ }
99
+ session.messages.push(assistantMsg);
93
100
  session.lastActivity = Date.now();
94
101
  // Store API-reported token counts if available
95
102
  if (options.tokenUsage) {
@@ -275,7 +282,11 @@ export class ConversationMemoryManager {
275
282
  async getStats() {
276
283
  await this.ensureInitialized();
277
284
  const sessions = Array.from(this.sessions.values());
278
- const totalTurns = sessions.reduce((sum, session) => sum + session.messages.length / MESSAGES_PER_TURN, 0);
285
+ // Pinned skill messages are extra rows inside a turn exclude them so
286
+ // a skill-activating turn still counts as one turn.
287
+ const totalTurns = sessions.reduce((sum, session) => sum +
288
+ session.messages.filter((msg) => !msg.metadata?.isSkill).length /
289
+ MESSAGES_PER_TURN, 0);
279
290
  return {
280
291
  totalSessions: sessions.length,
281
292
  totalTurns,
@@ -457,6 +457,12 @@ export class RedisConversationMemoryManager {
457
457
  };
458
458
  conversation.messages.push(userMsg);
459
459
  await this.flushPendingToolData(conversation, options.sessionId, normalizedUserId);
460
+ // Pinned skill activations ride between ask and answer, mirroring
461
+ // the actual order (ask → skill loaded → answer). Stored verbatim —
462
+ // skill instructions are never truncated.
463
+ if (options.skillMessages && options.skillMessages.length > 0) {
464
+ conversation.messages.push(...options.skillMessages);
465
+ }
460
466
  const assistantMsg = {
461
467
  id: randomUUID(),
462
468
  timestamp: this.generateTimestamp(),
@@ -1097,7 +1103,10 @@ User message: "${userMessage}"`;
1097
1103
  const conversationData = await this.redisClient.get(key);
1098
1104
  const conversation = deserializeConversation(conversationData);
1099
1105
  if (conversation?.messages) {
1100
- totalTurns += conversation.messages.length / MESSAGES_PER_TURN;
1106
+ // Pinned skill messages are extra rows inside a turn — exclude them
1107
+ // so a skill-activating turn still counts as one turn.
1108
+ totalTurns +=
1109
+ conversation.messages.filter((msg) => !msg.metadata?.isSkill).length / MESSAGES_PER_TURN;
1101
1110
  }
1102
1111
  }
1103
1112
  return {
@@ -274,7 +274,7 @@ export declare class NeuroLink {
274
274
  */
275
275
  private ensureSkillsReady;
276
276
  /**
277
- * Register the built-in skill tools (search_skills / list_skills, plus
277
+ * Register the built-in skill tools (list_skills, plus
278
278
  * mutation tools when allowMutations is set). Follows the
279
279
  * registerMemoryRetrievalTools() pattern: registered via registerTool()
280
280
  * so they land in the "user-defined" category that reaches the LLM tool
@@ -282,11 +282,36 @@ export declare class NeuroLink {
282
282
  */
283
283
  private registerSkillTools;
284
284
  /**
285
- * Append the compact skills index (names + descriptions, never
286
- * instructions) to the system prompt for one generate()/stream() call.
287
- * Fails open: any error leaves the prompt untouched.
288
- */
289
- private applySkillsPromptIndex;
285
+ * Skills augmentation for one generate()/stream() call:
286
+ * 1. Discovery surface the skills listing per the resolved mode:
287
+ * embedded in the use_skill tool description ("tool", default) or
288
+ * appended to the system prompt ("system-prompt").
289
+ * 2. Per-call tools — inject use_skill + read_skill_resource into
290
+ * options.tools (the RAG-tool pattern; same-name entries shadow
291
+ * registered tools) with the sessionId closure-bound so activations
292
+ * pin to the session.
293
+ * 3. Preload — activate host-requested skills up front, injecting their
294
+ * instructions into this call's system prompt and pinning them.
295
+ * Fails open: any error leaves the call untouched.
296
+ */
297
+ private applySkillsAugmentation;
298
+ /** Session id for skill activation tracking, from the call context. */
299
+ private resolveSkillSessionId;
300
+ /** User id for skill-session hydration (Redis keys sessions by userId). */
301
+ private resolveSkillUserId;
302
+ /**
303
+ * Activate host-requested skills before the model runs: instructions go
304
+ * into this call's system prompt; when persisting, the activation is
305
+ * pinned so later turns replay it from history instead. Unknown or
306
+ * already-active names are skipped with a warn/debug log (fail-open).
307
+ */
308
+ private preloadSkills;
309
+ /**
310
+ * Pinned skill messages recorded during this turn, ready for
311
+ * StoreConversationTurnOptions.skillMessages. Empty when skills are off
312
+ * or nothing was activated.
313
+ */
314
+ private drainPendingSkillMessages;
290
315
  /**
291
316
  * Programmatic access to the skills subsystem (search/list/get/mutations).
292
317
  * Returns null when skills are not configured or failed to initialize.