@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
@@ -6,15 +6,28 @@
6
6
  *
7
7
  * Architecture mirrors the Hippocampus memory subsystem: a `skills` config
8
8
  * on the NeuroLink constructor lazily initializes a SkillsManager, which
9
- * auto-registers built-in tools (search_skills / list_skills, plus gated
10
- * mutation tools) and optionally injects a compact skills index into the
11
- * system prompt of each generate()/stream() call.
9
+ * auto-registers built-in tools (list_skills, plus gated mutation tools)
10
+ * and augments each generate()/stream() call with the discovery listing
11
+ * plus per-call use_skill / read_skill_resource tools.
12
12
  *
13
13
  * Naming: every exported type carries a `Skill` prefix to satisfy the
14
14
  * `unique-type-names` ESLint rule.
15
15
  */
16
+ import type { ChatMessage } from "./conversation.js";
16
17
  /** Visibility of a skill: available everywhere, or only in specific scopes. */
17
18
  export type SkillScopeKind = "global" | "scoped";
19
+ /**
20
+ * Reference to an auxiliary file bundled with a skill (progressive
21
+ * disclosure level 3). Resources are read into context on demand via the
22
+ * read_skill_resource tool — a skill's SKILL.md should stay lean and point
23
+ * to resources for rarely-needed detail.
24
+ */
25
+ export type SkillResourceRef = {
26
+ /** Path relative to the skill's directory, e.g. "references/edge-cases.md". */
27
+ path: string;
28
+ /** Size in bytes when known (listing hint only). */
29
+ size?: number;
30
+ };
18
31
  /** Lifecycle status. Deletes are soft — deprecated skills stay in storage. */
19
32
  export type SkillLifecycleStatus = "active" | "deprecated";
20
33
  /**
@@ -47,6 +60,12 @@ export type SkillDefinition = {
47
60
  createdAt?: string;
48
61
  /** ISO timestamp of last update. */
49
62
  updatedAt?: string;
63
+ /**
64
+ * Auxiliary files bundled with the skill, readable on demand through
65
+ * read_skill_resource. Populated by stores that support resources
66
+ * (directory-layout filesystem skills, S3, Redis).
67
+ */
68
+ resources?: SkillResourceRef[];
50
69
  /** Free-form host metadata (audit fields, approval references, …). */
51
70
  metadata?: Record<string, unknown>;
52
71
  };
@@ -69,12 +88,24 @@ export type SkillStore = {
69
88
  index(): Promise<SkillIndexItem[]>;
70
89
  /** Optional: drop any internal caches (called after mutations). */
71
90
  invalidate?(): void;
91
+ /**
92
+ * Optional: fetch an auxiliary resource file bundled with a skill.
93
+ * `resourcePath` is relative to the skill (e.g. "references/forms.md").
94
+ * Null when the skill or resource is absent. Stores without resource
95
+ * support simply omit this method.
96
+ */
97
+ getResource?(id: string, resourcePath: string): Promise<string | null>;
72
98
  };
73
99
  /** In-process store, optionally seeded. Good for tests and embedded use. */
74
100
  export type SkillMemoryStorageConfig = {
75
101
  type: "memory";
76
102
  /** Initial skills to seed the store with. */
77
103
  skills?: SkillDefinition[];
104
+ /**
105
+ * Resource file contents keyed by skill id → relative path.
106
+ * E.g. `{ "my-skill": { "references/forms.md": "..." } }`.
107
+ */
108
+ resources?: Record<string, Record<string, string>>;
78
109
  };
79
110
  /**
80
111
  * Directory-backed store. Reads three layouts:
@@ -154,6 +185,15 @@ export type SkillS3IndexDocument = {
154
185
  lastUpdated: string;
155
186
  skills: SkillIndexItem[];
156
187
  };
188
+ /** Result of a conditional (ETag) object read. */
189
+ export type SkillS3ConditionalGetResult = {
190
+ /** Object body; null when the key is absent. */
191
+ body: string | null;
192
+ /** ETag of the returned body, for the next conditional read. */
193
+ etag?: string;
194
+ /** True when the object is unchanged since the supplied ETag (no body). */
195
+ notModified?: boolean;
196
+ };
157
197
  /**
158
198
  * Minimal object-storage operations the S3 skill store runs on. The
159
199
  * default implementation is created lazily from @aws-sdk/client-s3;
@@ -166,9 +206,15 @@ export type SkillS3ObjectOps = {
166
206
  deleteObject(key: string): Promise<void>;
167
207
  /** List all object keys under a prefix (paginated internally). */
168
208
  listKeys(prefix: string): Promise<string[]>;
209
+ /**
210
+ * Optional: ETag-conditional read (If-None-Match). Used for index.json
211
+ * refreshes so an unchanged index costs a 304 instead of a full download.
212
+ * Ops without it fall back to plain getObject.
213
+ */
214
+ getObjectConditional?(key: string, etag?: string): Promise<SkillS3ConditionalGetResult>;
169
215
  };
170
216
  export type SkillsStorageConfig = SkillMemoryStorageConfig | SkillFilesystemStorageConfig | SkillS3StorageConfig | SkillRedisStorageConfig | SkillCustomStorageConfig;
171
- /** Query accepted by SkillsManager.search() and the search_skills tool. */
217
+ /** Query accepted by SkillsManager.search() (programmatic + CLI search). */
172
218
  export type SkillSearchQuery = {
173
219
  /** Keyword matched (case-insensitive substring) against name, displayName, and description. */
174
220
  query?: string;
@@ -239,14 +285,38 @@ export type SkillsConfig = {
239
285
  /** Persistence backend. Default: `{ type: "memory" }`. */
240
286
  storage?: SkillsStorageConfig;
241
287
  /**
242
- * Inject a compact skills index (names + descriptions, never instructions)
243
- * into the system prompt of each generate()/stream() call. Default: true.
244
- * Set false for curator-style pure tool-driven disclosure.
288
+ * Where the skills listing (names + descriptions, never instructions)
289
+ * surfaces for model-driven discovery:
290
+ * - "tool" (default): an `<available_skills>` block embedded in the
291
+ * use_skill tool description — the Claude Code pattern. Keeps the
292
+ * host's system prompt untouched and the listing cache-stable.
293
+ * - "system-prompt": a "## Available Skills" index appended to the
294
+ * system prompt instead.
295
+ * - "none": no listing anywhere; discovery only via list_skills.
296
+ */
297
+ discovery?: SkillDiscoveryMode;
298
+ /**
299
+ * Character budget for the "tool" discovery listing. When the full
300
+ * listing exceeds it, every description is shortened uniformly (first
301
+ * sentence, then a hard cap) so the render stays a pure function of the
302
+ * index — byte-stable across calls; names are never dropped.
303
+ * Default: 15000.
245
304
  */
246
- promptIndex?: boolean;
305
+ listingBudgetChars?: number;
306
+ /**
307
+ * Pin activated skill instructions into session history so later turns
308
+ * replay them verbatim (byte-stable, provider-cacheable) instead of
309
+ * re-fetching the skill. Requires conversation memory + a sessionId on
310
+ * the call. Default: true.
311
+ */
312
+ sessionPersistence?: boolean;
247
313
  /** Maximum skills hydrated (with instructions) per search. Default: 5. */
248
314
  maxMatches?: number;
249
- /** Maximum entries rendered in the prompt index before truncation. Default: 50. */
315
+ /**
316
+ * Maximum entries rendered by the "system-prompt" discovery mode before
317
+ * truncation. Default: 50. The "tool" mode is bounded by
318
+ * listingBudgetChars instead and never drops entries.
319
+ */
250
320
  promptIndexMaxItems?: number;
251
321
  /** Index cache TTL in milliseconds. Default: 30000. 0 disables caching. */
252
322
  indexCacheTtlMs?: number;
@@ -265,6 +335,30 @@ export type SkillsConfig = {
265
335
  */
266
336
  onMutationRequest?: (action: SkillMutationAction) => Promise<SkillMutationDecision>;
267
337
  };
338
+ /** Where the skills discovery listing surfaces. See SkillsConfig.discovery. */
339
+ export type SkillDiscoveryMode = "tool" | "system-prompt" | "none";
340
+ /**
341
+ * One activated skill in a session: which skill, at which version, when.
342
+ * Sessions pin the version active at activation time — a mid-session skill
343
+ * update never mutates instructions the model has already loaded.
344
+ */
345
+ export type SkillActivationRecord = {
346
+ skillId: string;
347
+ name: string;
348
+ version: number;
349
+ /** ISO timestamp of activation. */
350
+ activatedAt: string;
351
+ };
352
+ /**
353
+ * Structural view of the per-session activation tracker consumed by the
354
+ * skill tools factory.
355
+ */
356
+ export type SkillSessionStateLike = {
357
+ isActive: (sessionId: string, skillId: string, name: string) => boolean;
358
+ getActivation: (sessionId: string, skillId: string, name?: string) => SkillActivationRecord | undefined;
359
+ recordActivation: (sessionId: string, skill: SkillDefinition) => ChatMessage;
360
+ hydrate: (sessionId: string, storedMessages: ChatMessage[]) => void;
361
+ };
268
362
  /**
269
363
  * Structural view of SkillsManager consumed by the skill tools factory —
270
364
  * keeps skillTools.ts decoupled from the concrete manager class.
@@ -272,8 +366,38 @@ export type SkillsConfig = {
272
366
  export type SkillsManagerLike = {
273
367
  search: (query: SkillSearchQuery) => Promise<SkillDefinition[]>;
274
368
  list: (scopeId?: string) => Promise<SkillIndexItem[]>;
369
+ get: (idOrName: string) => Promise<SkillDefinition | null>;
370
+ getResource: (idOrName: string, resourcePath: string) => Promise<string | null>;
371
+ sessions: SkillSessionStateLike;
275
372
  requestMutation: (action: SkillMutationAction) => Promise<SkillMutationResult>;
276
373
  };
374
+ /**
375
+ * Per-call context bound into the use_skill / read_skill_resource tools at
376
+ * injection time (prepareGenerate/prepareStream). The sessionId is captured
377
+ * by closure so activation state is tracked without relying on runtime tool
378
+ * context plumbing.
379
+ */
380
+ export type SkillCallToolsContext = {
381
+ /** Session the call belongs to; absent → activation state is per-turn only. */
382
+ sessionId?: string;
383
+ /** Scope filter applied when resolving skills for this call. */
384
+ scopeId?: string;
385
+ /**
386
+ * Pin activated instructions into session history after the turn.
387
+ * Mirrors SkillsConfig.sessionPersistence resolved for this call.
388
+ */
389
+ sessionPersistence: boolean;
390
+ /** Discovery mode resolved for this call — shapes the use_skill description. */
391
+ discovery: SkillDiscoveryMode;
392
+ /** Rendered `<available_skills>` block for "tool" discovery; null when empty. */
393
+ listing?: string | null;
394
+ /**
395
+ * Stored session history loader used to hydrate activation state before
396
+ * every dedup check (restart/multi-instance/failed-persistence safety).
397
+ * Invoked once per use_skill / read_skill_resource attempt.
398
+ */
399
+ getStoredMessages?: (sessionId: string) => Promise<ChatMessage[]>;
400
+ };
277
401
  /** Options for the createSkillTools factory. */
278
402
  export type SkillToolsOptions = {
279
403
  /** Include skill_create / skill_update / skill_delete. Default: false. */
@@ -285,12 +409,19 @@ export type SkillToolsOptions = {
285
409
  * config (same precedence convention as per-call credentials).
286
410
  */
287
411
  export type SkillsCallOptions = {
288
- /** Master toggle for this call (prompt index only tools stay registered). Default: true. */
412
+ /** Master toggle for this call (listing + per-call tools). Default: true. */
289
413
  enabled?: boolean;
290
- /** Per-call override of SkillsConfig.promptIndex. */
291
- promptIndex?: boolean;
292
- /** Scope filter for the prompt index on this call. Overrides defaultScopeId. */
414
+ /** Per-call override of SkillsConfig.discovery. */
415
+ discovery?: SkillDiscoveryMode;
416
+ /** Scope filter for the listing and skill resolution on this call. Overrides defaultScopeId. */
293
417
  scopeId?: string;
294
- /** Restrict the prompt index to skills carrying at least one of these tags. */
418
+ /** Restrict the listing to skills carrying at least one of these tags. */
295
419
  tags?: string[];
420
+ /**
421
+ * Skill names to activate at the start of this call: their full
422
+ * instructions are injected up front (and pinned to the session when
423
+ * sessionPersistence is on), without waiting for the model to invoke
424
+ * use_skill. Already-active skills are skipped.
425
+ */
426
+ preload?: string[];
296
427
  };
@@ -6,9 +6,9 @@
6
6
  *
7
7
  * Architecture mirrors the Hippocampus memory subsystem: a `skills` config
8
8
  * on the NeuroLink constructor lazily initializes a SkillsManager, which
9
- * auto-registers built-in tools (search_skills / list_skills, plus gated
10
- * mutation tools) and optionally injects a compact skills index into the
11
- * system prompt of each generate()/stream() call.
9
+ * auto-registers built-in tools (list_skills, plus gated mutation tools)
10
+ * and augments each generate()/stream() call with the discovery listing
11
+ * plus per-call use_skill / read_skill_resource tools.
12
12
  *
13
13
  * Naming: every exported type carries a `Skill` prefix to satisfy the
14
14
  * `unique-type-names` ESLint rule.
@@ -28,7 +28,7 @@ export declare function getConversationMessages(conversationMemory: Conversation
28
28
  * Store conversation turn for future context
29
29
  * Saves user messages and AI responses for conversation memory
30
30
  */
31
- export declare function storeConversationTurn(conversationMemory: ConversationMemoryManager | RedisConversationMemoryManager | null | undefined, originalOptions: TextGenerationOptions, result: TextGenerationResult, startTimeStamp?: Date | undefined, requestId?: string): Promise<void>;
31
+ export declare function storeConversationTurn(conversationMemory: ConversationMemoryManager | RedisConversationMemoryManager | null | undefined, originalOptions: TextGenerationOptions, result: TextGenerationResult, startTimeStamp?: Date | undefined, requestId?: string, skillMessages?: ChatMessage[]): Promise<void>;
32
32
  /**
33
33
  * Build context messages from pointer onwards (token-based memory)
34
34
  * Returns summary message (if exists) + all messages after the summarized pointer
@@ -227,7 +227,7 @@ export async function getConversationMessages(conversationMemory, options) {
227
227
  * Store conversation turn for future context
228
228
  * Saves user messages and AI responses for conversation memory
229
229
  */
230
- export async function storeConversationTurn(conversationMemory, originalOptions, result, startTimeStamp, requestId) {
230
+ export async function storeConversationTurn(conversationMemory, originalOptions, result, startTimeStamp, requestId, skillMessages) {
231
231
  logger.debug("[conversationMemoryUtils] storeConversationTurn called", {
232
232
  requestId,
233
233
  hasMemory: !!conversationMemory,
@@ -369,6 +369,9 @@ export async function storeConversationTurn(conversationMemory, originalOptions,
369
369
  enableSummarization: originalOptions.enableSummarization,
370
370
  requestId,
371
371
  events: toolActivityEvents,
372
+ ...(skillMessages && skillMessages.length > 0
373
+ ? { skillMessages }
374
+ : {}),
372
375
  tokenUsage: result.usage
373
376
  ? {
374
377
  inputTokens: result.usage.input,
@@ -450,6 +453,12 @@ export function buildContextFromPointer(session, requestId) {
450
453
  return session.messages;
451
454
  }
452
455
  const messagesAfterPointer = session.messages.slice(pointerIndex + 1);
456
+ // Pinned skill instructions must survive summarization: any skill message
457
+ // that fell behind the pointer is re-included verbatim after the summary,
458
+ // so an activated skill keeps its full instructions for the whole session.
459
+ const pinnedSkillMessages = session.messages
460
+ .slice(0, pointerIndex + 1)
461
+ .filter((msg) => msg.metadata?.isSkill);
453
462
  // Construct context: summary message + recent messages
454
463
  const summaryMessage = {
455
464
  id: `summary-${session.summarizedUpToMessageId}`,
@@ -468,7 +477,11 @@ export function buildContextFromPointer(session, requestId) {
468
477
  totalMessages: session.messages.length,
469
478
  summaryLength: session.summarizedMessage.length,
470
479
  });
471
- const contextMessages = [summaryMessage, ...messagesAfterPointer];
480
+ const contextMessages = [
481
+ summaryMessage,
482
+ ...pinnedSkillMessages,
483
+ ...messagesAfterPointer,
484
+ ];
472
485
  // Log context built for LLM with structural metadata
473
486
  const totalChars = contextMessages.reduce((sum, msg) => sum + msg.content.length, 0);
474
487
  logger.info("[ConversationMemory] Context built for LLM", {
@@ -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.
package/dist/neurolink.js CHANGED
@@ -54,8 +54,10 @@ import { MCPToolRegistry } from "./mcp/toolRegistry.js";
54
54
  import { resolveDynamicArgument } from "./dynamic/dynamicResolver.js";
55
55
  import { initializeHippocampus } from "./memory/hippocampusInitializer.js";
56
56
  import { createMemoryRetrievalTools } from "./memory/memoryRetrievalTools.js";
57
+ import { isSkillVisibleInScope } from "./skills/skillMatcher.js";
58
+ import { buildSkillActivationMessage } from "./skills/skillSessionTracker.js";
57
59
  import { SkillsManager } from "./skills/skillsManager.js";
58
- import { createSkillTools } from "./skills/skillTools.js";
60
+ import { createSkillCallTools, createSkillTools } from "./skills/skillTools.js";
59
61
  import { getMetricsAggregator, MetricsAggregator, } from "./observability/metricsAggregator.js";
60
62
  import { SpanStatus, SpanType, CircuitBreakerOpenError, ConversationMemoryError, ModelAccessDeniedError, } from "./types/index.js";
61
63
  import { SpanSerializer } from "./observability/utils/spanSerializer.js";
@@ -1276,7 +1278,7 @@ export class NeuroLink {
1276
1278
  return this.skillsManagerInstance;
1277
1279
  }
1278
1280
  /**
1279
- * Register the built-in skill tools (search_skills / list_skills, plus
1281
+ * Register the built-in skill tools (list_skills, plus
1280
1282
  * mutation tools when allowMutations is set). Follows the
1281
1283
  * registerMemoryRetrievalTools() pattern: registered via registerTool()
1282
1284
  * so they land in the "user-defined" category that reaches the LLM tool
@@ -1300,19 +1302,22 @@ export class NeuroLink {
1300
1302
  logger.info(`[NeuroLink] Registered ${Object.keys(canonicalTools).length} skill tools`, { allowMutations: this.skillsConfig?.allowMutations === true });
1301
1303
  }
1302
1304
  /**
1303
- * Append the compact skills index (names + descriptions, never
1304
- * instructions) to the system prompt for one generate()/stream() call.
1305
- * Fails open: any error leaves the prompt untouched.
1305
+ * Skills augmentation for one generate()/stream() call:
1306
+ * 1. Discovery surface the skills listing per the resolved mode:
1307
+ * embedded in the use_skill tool description ("tool", default) or
1308
+ * appended to the system prompt ("system-prompt").
1309
+ * 2. Per-call tools — inject use_skill + read_skill_resource into
1310
+ * options.tools (the RAG-tool pattern; same-name entries shadow
1311
+ * registered tools) with the sessionId closure-bound so activations
1312
+ * pin to the session.
1313
+ * 3. Preload — activate host-requested skills up front, injecting their
1314
+ * instructions into this call's system prompt and pinning them.
1315
+ * Fails open: any error leaves the call untouched.
1306
1316
  */
1307
- async applySkillsPromptIndex(options) {
1317
+ async applySkillsAugmentation(options) {
1308
1318
  if (!this.skillsConfig?.enabled || options.skills?.enabled === false) {
1309
1319
  return;
1310
1320
  }
1311
- // Per-call promptIndex wins over instance config; default is on.
1312
- const promptIndexEnabled = options.skills?.promptIndex ?? this.skillsConfig.promptIndex ?? true;
1313
- if (!promptIndexEnabled) {
1314
- return;
1315
- }
1316
1321
  // Media-only modes have no meaningful text prompt to augment.
1317
1322
  const mode = options.output?.mode;
1318
1323
  if (mode === "avatar" ||
@@ -1326,26 +1331,142 @@ export class NeuroLink {
1326
1331
  if (!manager) {
1327
1332
  return;
1328
1333
  }
1329
- const block = await manager.buildPromptIndex({
1330
- ...(options.skills?.scopeId !== undefined
1331
- ? { scopeId: options.skills.scopeId }
1332
- : {}),
1333
- ...(options.skills?.tags !== undefined
1334
- ? { tags: options.skills.tags }
1335
- : {}),
1334
+ const discovery = options.skills?.discovery ?? this.skillsConfig.discovery ?? "tool";
1335
+ const scopeId = options.skills?.scopeId ?? this.skillsConfig.defaultScopeId;
1336
+ const tags = options.skills?.tags;
1337
+ const sessionId = this.resolveSkillSessionId(options.context) ??
1338
+ this.resolveSkillSessionId(options);
1339
+ const userId = this.resolveSkillUserId(options.context) ??
1340
+ this.resolveSkillUserId(options);
1341
+ // Pinning requires somewhere to pin: without conversation memory the
1342
+ // drained messages would be silently discarded while dedup reports
1343
+ // already_loaded — so persistence is only on when memory is
1344
+ // configured (or already initialized).
1345
+ const memoryAvailable = Boolean(this.conversationMemory) ||
1346
+ Boolean(this.conversationMemoryConfig?.conversationMemory?.enabled);
1347
+ const sessionPersistence = (this.skillsConfig.sessionPersistence ?? true) &&
1348
+ Boolean(sessionId) &&
1349
+ memoryAvailable;
1350
+ const visibility = {
1351
+ ...(scopeId !== undefined ? { scopeId } : {}),
1352
+ ...(tags !== undefined ? { tags } : {}),
1353
+ };
1354
+ if (discovery === "system-prompt") {
1355
+ const block = await manager.buildPromptIndex(visibility);
1356
+ if (block) {
1357
+ options.systemPrompt = options.systemPrompt
1358
+ ? `${options.systemPrompt}\n\n${block}`
1359
+ : block;
1360
+ }
1361
+ }
1362
+ const listing = discovery === "tool"
1363
+ ? await manager.buildToolListing(visibility)
1364
+ : null;
1365
+ const callTools = createSkillCallTools(() => this.ensureSkillsReady(), {
1366
+ ...(sessionId ? { sessionId } : {}),
1367
+ ...(scopeId !== undefined ? { scopeId } : {}),
1368
+ sessionPersistence,
1369
+ discovery,
1370
+ listing,
1371
+ // userId rides by closure: Redis memory keys sessions by
1372
+ // userId:sessionId, so hydration must read the same key the
1373
+ // store-turn path writes.
1374
+ getStoredMessages: async (sid) => this.conversationMemory
1375
+ ? await this.conversationMemory.getSessionMessages(sid, userId)
1376
+ : [],
1336
1377
  });
1337
- if (block) {
1338
- options.systemPrompt = options.systemPrompt
1339
- ? `${options.systemPrompt}\n\n${block}`
1340
- : block;
1341
- logger.debug("[NeuroLink] Skills prompt index injected", {
1342
- blockLength: block.length,
1378
+ // Caller-supplied per-call tools win over the injected ones (a host
1379
+ // may bring its own use_skill); the injected tools still shadow any
1380
+ // registered base tool of the same name via the provider merge.
1381
+ options.tools = {
1382
+ ...callTools,
1383
+ ...(options.tools ?? {}),
1384
+ };
1385
+ if (options.skills?.preload?.length) {
1386
+ await this.preloadSkills(manager, options, options.skills.preload, {
1387
+ ...(sessionId ? { sessionId } : {}),
1388
+ ...(userId ? { userId } : {}),
1389
+ sessionPersistence,
1390
+ ...(scopeId !== undefined ? { scopeId } : {}),
1343
1391
  });
1344
1392
  }
1393
+ logger.debug("[NeuroLink] Skills augmentation applied", {
1394
+ discovery,
1395
+ listingLength: listing?.length ?? 0,
1396
+ sessionPersistence,
1397
+ preloadCount: options.skills?.preload?.length ?? 0,
1398
+ });
1345
1399
  }
1346
1400
  catch (error) {
1347
- logger.warn("[NeuroLink] Skills prompt index injection failed — continuing without it", { error: error instanceof Error ? error.message : String(error) });
1401
+ logger.warn("[NeuroLink] Skills augmentation failed — continuing without skills", { error: error instanceof Error ? error.message : String(error) });
1402
+ }
1403
+ }
1404
+ /** Session id for skill activation tracking, from the call context. */
1405
+ resolveSkillSessionId(context) {
1406
+ const fromContext = context
1407
+ ?.sessionId;
1408
+ return typeof fromContext === "string" && fromContext
1409
+ ? fromContext
1410
+ : undefined;
1411
+ }
1412
+ /** User id for skill-session hydration (Redis keys sessions by userId). */
1413
+ resolveSkillUserId(context) {
1414
+ const fromContext = context?.userId;
1415
+ return typeof fromContext === "string" && fromContext
1416
+ ? fromContext
1417
+ : undefined;
1418
+ }
1419
+ /**
1420
+ * Activate host-requested skills before the model runs: instructions go
1421
+ * into this call's system prompt; when persisting, the activation is
1422
+ * pinned so later turns replay it from history instead. Unknown or
1423
+ * already-active names are skipped with a warn/debug log (fail-open).
1424
+ */
1425
+ async preloadSkills(manager, options, names, call) {
1426
+ const { sessionId, userId, sessionPersistence, scopeId } = call;
1427
+ for (const name of names) {
1428
+ const skill = await manager.get(name);
1429
+ if (!skill || !isSkillVisibleInScope(skill, scopeId)) {
1430
+ logger.warn("[NeuroLink] Preload skill not found — skipping", {
1431
+ skill: name,
1432
+ });
1433
+ continue;
1434
+ }
1435
+ let block;
1436
+ if (sessionId && sessionPersistence) {
1437
+ // Always hydrate (empty history when memory isn't initialized yet):
1438
+ // the tracker derives truth from stored pins + this turn's pending,
1439
+ // never from stale in-process records.
1440
+ manager.sessions.hydrate(sessionId, this.conversationMemory
1441
+ ? await this.conversationMemory.getSessionMessages(sessionId, userId)
1442
+ : []);
1443
+ if (manager.sessions.isActive(sessionId, skill.id, skill.name)) {
1444
+ continue;
1445
+ }
1446
+ block = manager.sessions.recordActivation(sessionId, skill).content;
1447
+ }
1448
+ else {
1449
+ block = buildSkillActivationMessage(skill).content;
1450
+ }
1451
+ options.systemPrompt = options.systemPrompt
1452
+ ? `${options.systemPrompt}\n\n${block}`
1453
+ : block;
1454
+ }
1455
+ }
1456
+ /**
1457
+ * Pinned skill messages recorded during this turn, ready for
1458
+ * StoreConversationTurnOptions.skillMessages. Empty when skills are off
1459
+ * or nothing was activated.
1460
+ */
1461
+ drainPendingSkillMessages(sessionId) {
1462
+ if (typeof sessionId !== "string" || !sessionId) {
1463
+ return [];
1464
+ }
1465
+ const manager = this.skillsManagerInstance;
1466
+ if (!manager) {
1467
+ return [];
1348
1468
  }
1469
+ return manager.sessions.drainPending(sessionId);
1349
1470
  }
1350
1471
  /**
1351
1472
  * Programmatic access to the skills subsystem (search/list/get/mutations).
@@ -3610,9 +3731,9 @@ Current user's request: ${currentInput}`;
3610
3731
  });
3611
3732
  }
3612
3733
  }
3613
- // Skills: append the compact skills index to the system prompt so the
3614
- // model knows which skills exist (bodies load via search_skills).
3615
- await this.applySkillsPromptIndex(options);
3734
+ // Skills: surface the discovery listing and inject the per-call
3735
+ // use_skill / read_skill_resource tools (bodies load on activation).
3736
+ await this.applySkillsAugmentation(options);
3616
3737
  // Media-only modes (avatar, music, video, ppt) do not have a meaningful
3617
3738
  // text prompt to augment with memory — skip injection to avoid corrupting
3618
3739
  // the empty/synthesized input.text that was set for these modes.
@@ -4431,7 +4552,7 @@ Current user's request: ${currentInput}`;
4431
4552
  });
4432
4553
  const memStoreStart = Date.now();
4433
4554
  try {
4434
- await storeConversationTurn(this.conversationMemory, options, result, new Date(startTime), requestId);
4555
+ await storeConversationTurn(this.conversationMemory, options, result, new Date(startTime), requestId, this.drainPendingSkillMessages(options.context?.sessionId));
4435
4556
  this.recordMemorySpan("memory.store", { "memory.operation": "store", "memory.path": path }, Date.now() - memStoreStart, SpanStatus.OK);
4436
4557
  }
4437
4558
  catch (memoryError) {
@@ -7151,9 +7272,9 @@ Current user's request: ${currentInput}`;
7151
7272
  logger.warn("Memory retrieval failed:", error);
7152
7273
  }
7153
7274
  }
7154
- // Skills: append the compact skills index to the system prompt so the
7155
- // model knows which skills exist (bodies load via search_skills).
7156
- await this.applySkillsPromptIndex(options);
7275
+ // Skills: surface the discovery listing and inject the per-call
7276
+ // use_skill / read_skill_resource tools (bodies load on activation).
7277
+ await this.applySkillsAugmentation(options);
7157
7278
  // Apply orchestration if enabled and no specific provider/model requested
7158
7279
  if (this.enableOrchestration && !options.provider && !options.model) {
7159
7280
  try {
@@ -7526,6 +7647,7 @@ Current user's request: ${currentInput}`;
7526
7647
  }
7527
7648
  const memStoreStart = Date.now();
7528
7649
  try {
7650
+ const pendingSkillMessages = this.drainPendingSkillMessages(sessionId);
7529
7651
  await this.conversationMemory.storeConversationTurn({
7530
7652
  sessionId,
7531
7653
  userId,
@@ -7537,6 +7659,9 @@ Current user's request: ${currentInput}`;
7537
7659
  events: eventSequence.length > 0 ? eventSequence : undefined,
7538
7660
  requestId: enhancedOptions.context
7539
7661
  ?.requestId,
7662
+ ...(pendingSkillMessages.length > 0
7663
+ ? { skillMessages: pendingSkillMessages }
7664
+ : {}),
7540
7665
  });
7541
7666
  this.recordMemorySpan("memory.store", { "memory.operation": "store", "memory.path": "stream" }, Date.now() - memStoreStart, SpanStatus.OK);
7542
7667
  logger.debug("[NeuroLink.stream] Stored conversation turn with events", {
@@ -8075,8 +8200,10 @@ Current user's request: ${currentInput}`;
8075
8200
  }
8076
8201
  const memStoreStart = Date.now();
8077
8202
  try {
8203
+ const fallbackSessionId = sessionId || options.context?.sessionId;
8204
+ const pendingSkillMessages = self.drainPendingSkillMessages(fallbackSessionId);
8078
8205
  await self.conversationMemory.storeConversationTurn({
8079
- sessionId: sessionId || options.context?.sessionId,
8206
+ sessionId: fallbackSessionId,
8080
8207
  userId: userId || options.context?.userId,
8081
8208
  userMessage: originalPrompt ?? "",
8082
8209
  aiResponse: fallbackAccumulatedContent,
@@ -8086,6 +8213,9 @@ Current user's request: ${currentInput}`;
8086
8213
  requestId: enhancedOptions?.context?.requestId ||
8087
8214
  options.context
8088
8215
  ?.requestId,
8216
+ ...(pendingSkillMessages.length > 0
8217
+ ? { skillMessages: pendingSkillMessages }
8218
+ : {}),
8089
8219
  });
8090
8220
  self.recordMemorySpan("memory.store", { "memory.operation": "store", "memory.path": "fallback-stream" }, Date.now() - memStoreStart, SpanStatus.OK);
8091
8221
  }
@@ -35,5 +35,10 @@ export declare function loadAccountQuota(accountKey: string): Promise<AccountQuo
35
35
  * Update quota for a single account.
36
36
  * Updates in-memory cache immediately (non-blocking),
37
37
  * then debounces the disk write to every 5 seconds.
38
+ *
39
+ * Loads the persisted file into the cache before the first write so a save
40
+ * after a process restart merges with existing entries instead of rewriting
41
+ * the file with only the accounts used since boot (which silently erased
42
+ * other accounts' snapshots and blinded quota-aware routing to them).
38
43
  */
39
44
  export declare function saveAccountQuota(accountKey: string, quota: AccountQuota): Promise<void>;