@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
@@ -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
  }
@@ -9,12 +9,41 @@
9
9
  import type { SkillDefinition, SkillIndexItem, SkillSearchQuery } from "../types/index.js";
10
10
  /** Strip instructions from a full definition to form an index entry. */
11
11
  export declare function toSkillIndexItem(skill: SkillDefinition): SkillIndexItem;
12
+ /**
13
+ * Sort index entries by name (byte order, locale-independent) so every
14
+ * listing renders byte-identically across calls and store backends.
15
+ * Store enumeration order (Redis SCAN, fs.readdir, S3 listings) is not
16
+ * stable — an unsorted listing would shuffle between calls and invalidate
17
+ * provider prompt caches. Returns a new array.
18
+ */
19
+ export declare function sortSkillIndex(items: SkillIndexItem[]): SkillIndexItem[];
12
20
  /** Filter index entries by query/tag/scope. Active skills only. */
13
21
  export declare function filterSkillIndex(items: SkillIndexItem[], query: SkillSearchQuery): SkillIndexItem[];
14
22
  /**
15
- * Render the compact skills index injected into the system prompt.
16
- * Names + descriptions only instructions are never included; the model
17
- * loads them via search_skills. Returns null when nothing is visible so
18
- * callers can skip injection entirely.
23
+ * Reject resource paths that could escape a skill's namespace: absolute
24
+ * paths, traversal segments, and empty segments. The manager validates
25
+ * before reaching any store this is defense in depth for stores whose
26
+ * keys are built by concatenation (S3, Redis) or path joining.
27
+ */
28
+ export declare function isSafeSkillResourcePath(resourcePath: string): boolean;
29
+ /** Whether a skill is visible from the calling scope. */
30
+ export declare function isSkillVisibleInScope(skill: Pick<SkillDefinition, "scope" | "scopeIds">, scopeId?: string): boolean;
31
+ /**
32
+ * Render the `<available_skills>` block embedded in the use_skill tool
33
+ * description ("tool" discovery mode). One line per skill — name,
34
+ * description, tags — instructions never appear here.
35
+ *
36
+ * Budget handling is uniform and stateless: over budget, every
37
+ * description drops to its first sentence, then to a hard 80-char cap.
38
+ * The render is a pure function of the (sorted) index, so the tool
39
+ * description stays byte-identical across calls — a prerequisite for
40
+ * provider prompt caching. Names are never dropped.
41
+ */
42
+ export declare function renderSkillListing(items: SkillIndexItem[], budgetChars: number): string | null;
43
+ /**
44
+ * Render the compact skills index appended to the system prompt
45
+ * ("system-prompt" discovery mode). Names + descriptions only —
46
+ * instructions are never included; the model loads them via use_skill.
47
+ * Returns null when nothing is visible so callers can skip injection.
19
48
  */
20
49
  export declare function formatSkillsPromptIndex(items: SkillIndexItem[], maxItems: number): string | null;
@@ -11,6 +11,16 @@ export function toSkillIndexItem(skill) {
11
11
  const { instructions: _instructions, ...indexItem } = skill;
12
12
  return indexItem;
13
13
  }
14
+ /**
15
+ * Sort index entries by name (byte order, locale-independent) so every
16
+ * listing renders byte-identically across calls and store backends.
17
+ * Store enumeration order (Redis SCAN, fs.readdir, S3 listings) is not
18
+ * stable — an unsorted listing would shuffle between calls and invalidate
19
+ * provider prompt caches. Returns a new array.
20
+ */
21
+ export function sortSkillIndex(items) {
22
+ return [...items].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
23
+ }
14
24
  /** Filter index entries by query/tag/scope. Active skills only. */
15
25
  export function filterSkillIndex(items, query) {
16
26
  const lowerQuery = query.query?.toLowerCase();
@@ -46,10 +56,75 @@ export function filterSkillIndex(items, query) {
46
56
  return query.limit !== undefined ? matched.slice(0, query.limit) : matched;
47
57
  }
48
58
  /**
49
- * Render the compact skills index injected into the system prompt.
50
- * Names + descriptions only instructions are never included; the model
51
- * loads them via search_skills. Returns null when nothing is visible so
52
- * callers can skip injection entirely.
59
+ * Reject resource paths that could escape a skill's namespace: absolute
60
+ * paths, traversal segments, and empty segments. The manager validates
61
+ * before reaching any store this is defense in depth for stores whose
62
+ * keys are built by concatenation (S3, Redis) or path joining.
63
+ */
64
+ export function isSafeSkillResourcePath(resourcePath) {
65
+ const normalized = resourcePath.replace(/\\/g, "/");
66
+ return (normalized.length > 0 &&
67
+ !normalized.startsWith("/") &&
68
+ !normalized.split("/").some((segment) => segment === ".." || segment === ""));
69
+ }
70
+ /** Whether a skill is visible from the calling scope. */
71
+ export function isSkillVisibleInScope(skill, scopeId) {
72
+ if (!scopeId || skill.scope !== "scoped") {
73
+ return true;
74
+ }
75
+ return (skill.scopeIds ?? []).includes(scopeId);
76
+ }
77
+ /** Trim a description to its first sentence (or the whole text when unsplittable). */
78
+ function firstSentence(description) {
79
+ const match = description.match(/^[^.!?]*[.!?]/);
80
+ return match ? match[0].trim() : description;
81
+ }
82
+ /**
83
+ * Render the `<available_skills>` block embedded in the use_skill tool
84
+ * description ("tool" discovery mode). One line per skill — name,
85
+ * description, tags — instructions never appear here.
86
+ *
87
+ * Budget handling is uniform and stateless: over budget, every
88
+ * description drops to its first sentence, then to a hard 80-char cap.
89
+ * The render is a pure function of the (sorted) index, so the tool
90
+ * description stays byte-identical across calls — a prerequisite for
91
+ * provider prompt caching. Names are never dropped.
92
+ */
93
+ export function renderSkillListing(items, budgetChars) {
94
+ if (items.length === 0) {
95
+ return null;
96
+ }
97
+ const render = (describe) => {
98
+ const lines = items.map((item) => {
99
+ const tags = item.tags && item.tags.length > 0
100
+ ? ` [tags: ${item.tags.join(", ")}]`
101
+ : "";
102
+ return `- ${item.name}: ${describe(item)}${tags}`;
103
+ });
104
+ return ["<available_skills>", ...lines, "</available_skills>"].join("\n");
105
+ };
106
+ const full = render((item) => item.description);
107
+ if (full.length <= budgetChars) {
108
+ return full;
109
+ }
110
+ const sentences = render((item) => firstSentence(item.description));
111
+ if (sentences.length <= budgetChars) {
112
+ return sentences;
113
+ }
114
+ const HARD_CAP = 80;
115
+ // Floor render — returned even if it still exceeds the budget.
116
+ return render((item) => {
117
+ const sentence = firstSentence(item.description);
118
+ return sentence.length > HARD_CAP
119
+ ? `${sentence.slice(0, HARD_CAP - 1)}…`
120
+ : sentence;
121
+ });
122
+ }
123
+ /**
124
+ * Render the compact skills index appended to the system prompt
125
+ * ("system-prompt" discovery mode). Names + descriptions only —
126
+ * instructions are never included; the model loads them via use_skill.
127
+ * Returns null when nothing is visible so callers can skip injection.
53
128
  */
54
129
  export function formatSkillsPromptIndex(items, maxItems) {
55
130
  if (items.length === 0) {
@@ -66,13 +141,13 @@ export function formatSkillsPromptIndex(items, maxItems) {
66
141
  return `- ${label}: ${item.description}${tags}`;
67
142
  });
68
143
  const truncationNote = items.length > visible.length
69
- ? `\n(${items.length - visible.length} more skills exist — use search_skills or list_skills to discover them.)`
144
+ ? `\n(${items.length - visible.length} more skills exist — use list_skills to discover them.)`
70
145
  : "";
71
146
  return ([
72
147
  "## Available Skills",
73
148
  "The following team-defined skills (SOPs, playbooks, workflows) are available.",
74
149
  "Before answering from general knowledge, check whether one applies to the user's request.",
75
- "To use a skill, call the search_skills tool to load its full instructions, then follow them exactly.",
150
+ "To use a skill, call the use_skill tool with its name to load the full instructions, then follow them exactly.",
76
151
  "",
77
152
  ...lines,
78
153
  ].join("\n") + truncationNote);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-session skill activation state.
3
+ *
4
+ * A skill activated via use_skill is pinned to its session: the tracker
5
+ * holds the pinned instruction message until the turn is persisted
6
+ * (drainPending → StoreConversationTurnOptions.skillMessages) and answers
7
+ * "is this skill already loaded?" so re-invocations return a cheap
8
+ * already_loaded note instead of the full body.
9
+ *
10
+ * The stored session history is the source of truth — pinned messages
11
+ * carry `metadata.isSkill`. hydrate() rebuilds a session's active set from
12
+ * stored messages on every activation attempt (use_skill is rare, one
13
+ * history read per attempt), so dedup stays correct across process
14
+ * restarts, multiple instances sharing a Redis memory backend, and failed
15
+ * persistence: a pin that never reached storage simply re-activates on
16
+ * the next turn instead of being falsely reported as loaded.
17
+ */
18
+ import type { ChatMessage, SkillActivationRecord, SkillDefinition } from "../types/index.js";
19
+ /** Render the pinned history message for an activated skill. */
20
+ export declare function buildSkillActivationMessage(skill: SkillDefinition): ChatMessage;
21
+ export declare class SkillSessionTracker {
22
+ /** sessionId → skillId → activation record. */
23
+ private active;
24
+ /** sessionId → pinned messages awaiting persistence into the session turn. */
25
+ private pending;
26
+ /** Whether the skill is already active (loaded) in this session. */
27
+ isActive(sessionId: string, skillId: string, name: string): boolean;
28
+ /** Activation record for an active skill (by id, falling back to name). */
29
+ getActivation(sessionId: string, skillId: string, name?: string): SkillActivationRecord | undefined;
30
+ /**
31
+ * Record an activation: marks the skill active and queues its pinned
32
+ * message for persistence when the turn is stored.
33
+ */
34
+ recordActivation(sessionId: string, skill: SkillDefinition): ChatMessage;
35
+ /**
36
+ * Rebuild the active set from stored session history, then merge the
37
+ * in-process pending activations (this turn's pins, not yet stored).
38
+ * Called before every dedup check — the rebuild keeps state truthful
39
+ * when other instances pinned skills or when a past pin never reached
40
+ * storage.
41
+ */
42
+ hydrate(sessionId: string, storedMessages: ChatMessage[]): void;
43
+ /**
44
+ * Remove and return the pinned messages queued for this session. Called
45
+ * once per turn by the memory-store path; an empty result means nothing
46
+ * was activated this turn.
47
+ */
48
+ drainPending(sessionId: string): ChatMessage[];
49
+ /** Active skill records for a session (listing/debugging). */
50
+ listActive(sessionId: string): SkillActivationRecord[];
51
+ private recordsFor;
52
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Per-session skill activation state.
3
+ *
4
+ * A skill activated via use_skill is pinned to its session: the tracker
5
+ * holds the pinned instruction message until the turn is persisted
6
+ * (drainPending → StoreConversationTurnOptions.skillMessages) and answers
7
+ * "is this skill already loaded?" so re-invocations return a cheap
8
+ * already_loaded note instead of the full body.
9
+ *
10
+ * The stored session history is the source of truth — pinned messages
11
+ * carry `metadata.isSkill`. hydrate() rebuilds a session's active set from
12
+ * stored messages on every activation attempt (use_skill is rare, one
13
+ * history read per attempt), so dedup stays correct across process
14
+ * restarts, multiple instances sharing a Redis memory backend, and failed
15
+ * persistence: a pin that never reached storage simply re-activates on
16
+ * the next turn instead of being falsely reported as loaded.
17
+ */
18
+ import { randomUUID } from "node:crypto";
19
+ /** Upper bound on tracked sessions; oldest are evicted first (insertion order). */
20
+ const MAX_TRACKED_SESSIONS = 1000;
21
+ /** Render the pinned history message for an activated skill. */
22
+ export function buildSkillActivationMessage(skill) {
23
+ const version = skill.version ?? 1;
24
+ return {
25
+ id: `skill-${randomUUID()}`,
26
+ role: "user",
27
+ content: `[Skill loaded: ${skill.name} v${version}]\n\n${skill.instructions}`,
28
+ timestamp: new Date().toISOString(),
29
+ metadata: {
30
+ isSkill: true,
31
+ skillId: skill.id,
32
+ skillName: skill.name,
33
+ skillVersion: version,
34
+ },
35
+ };
36
+ }
37
+ /** Activation record derived from a pinned history message, when it is one. */
38
+ function recordFromMessage(message) {
39
+ const meta = message.metadata;
40
+ if (!meta?.isSkill || !meta.skillId || !meta.skillName) {
41
+ return null;
42
+ }
43
+ return {
44
+ skillId: meta.skillId,
45
+ name: meta.skillName,
46
+ version: meta.skillVersion ?? 1,
47
+ activatedAt: message.timestamp ?? new Date(0).toISOString(),
48
+ };
49
+ }
50
+ export class SkillSessionTracker {
51
+ /** sessionId → skillId → activation record. */
52
+ active = new Map();
53
+ /** sessionId → pinned messages awaiting persistence into the session turn. */
54
+ pending = new Map();
55
+ /** Whether the skill is already active (loaded) in this session. */
56
+ isActive(sessionId, skillId, name) {
57
+ return Boolean(this.getActivation(sessionId, skillId, name));
58
+ }
59
+ /** Activation record for an active skill (by id, falling back to name). */
60
+ getActivation(sessionId, skillId, name) {
61
+ const records = this.active.get(sessionId);
62
+ if (!records) {
63
+ return undefined;
64
+ }
65
+ const byId = records.get(skillId);
66
+ if (byId || !name) {
67
+ return byId;
68
+ }
69
+ for (const record of records.values()) {
70
+ if (record.name === name) {
71
+ return record;
72
+ }
73
+ }
74
+ return undefined;
75
+ }
76
+ /**
77
+ * Record an activation: marks the skill active and queues its pinned
78
+ * message for persistence when the turn is stored.
79
+ */
80
+ recordActivation(sessionId, skill) {
81
+ const message = buildSkillActivationMessage(skill);
82
+ const record = recordFromMessage(message);
83
+ this.recordsFor(sessionId).set(skill.id, record);
84
+ const queue = this.pending.get(sessionId);
85
+ if (queue) {
86
+ queue.push(message);
87
+ }
88
+ else {
89
+ this.pending.set(sessionId, [message]);
90
+ }
91
+ return message;
92
+ }
93
+ /**
94
+ * Rebuild the active set from stored session history, then merge the
95
+ * in-process pending activations (this turn's pins, not yet stored).
96
+ * Called before every dedup check — the rebuild keeps state truthful
97
+ * when other instances pinned skills or when a past pin never reached
98
+ * storage.
99
+ */
100
+ hydrate(sessionId, storedMessages) {
101
+ const records = new Map();
102
+ for (const message of storedMessages) {
103
+ const record = recordFromMessage(message);
104
+ if (record && !records.has(record.skillId)) {
105
+ records.set(record.skillId, record);
106
+ }
107
+ }
108
+ for (const message of this.pending.get(sessionId) ?? []) {
109
+ const record = recordFromMessage(message);
110
+ if (record) {
111
+ records.set(record.skillId, record);
112
+ }
113
+ }
114
+ this.recordsFor(sessionId); // reserve the slot (applies the session cap)
115
+ this.active.set(sessionId, records);
116
+ }
117
+ /**
118
+ * Remove and return the pinned messages queued for this session. Called
119
+ * once per turn by the memory-store path; an empty result means nothing
120
+ * was activated this turn.
121
+ */
122
+ drainPending(sessionId) {
123
+ const queue = this.pending.get(sessionId);
124
+ if (!queue || queue.length === 0) {
125
+ return [];
126
+ }
127
+ this.pending.delete(sessionId);
128
+ return queue;
129
+ }
130
+ /** Active skill records for a session (listing/debugging). */
131
+ listActive(sessionId) {
132
+ return Array.from(this.active.get(sessionId)?.values() ?? []);
133
+ }
134
+ recordsFor(sessionId) {
135
+ let records = this.active.get(sessionId);
136
+ if (!records) {
137
+ if (this.active.size >= MAX_TRACKED_SESSIONS) {
138
+ const oldest = this.active.keys().next().value;
139
+ if (oldest !== undefined) {
140
+ this.active.delete(oldest);
141
+ this.pending.delete(oldest);
142
+ }
143
+ }
144
+ records = new Map();
145
+ this.active.set(sessionId, records);
146
+ }
147
+ return records;
148
+ }
149
+ }
150
+ //# sourceMappingURL=skillSessionTracker.js.map