@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
@@ -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>;
@@ -174,10 +174,17 @@ export async function loadAccountQuota(accountKey) {
174
174
  * Update quota for a single account.
175
175
  * Updates in-memory cache immediately (non-blocking),
176
176
  * then debounces the disk write to every 5 seconds.
177
+ *
178
+ * Loads the persisted file into the cache before the first write so a save
179
+ * after a process restart merges with existing entries instead of rewriting
180
+ * the file with only the accounts used since boot (which silently erased
181
+ * other accounts' snapshots and blinded quota-aware routing to them).
177
182
  */
178
183
  export async function saveAccountQuota(accountKey, quota) {
184
+ if (!cacheLoaded) {
185
+ await loadAccountQuotas();
186
+ }
179
187
  memoryCache[accountKey] = quota;
180
- cacheLoaded = true;
181
188
  dirty = true;
182
189
  scheduleFlush();
183
190
  }
@@ -44,18 +44,32 @@ declare function resetEpochToMs(resetEpoch: number | undefined, now: number): nu
44
44
  * allow a couple of jittered same-account retries, then a short cooldown.
45
45
  */
46
46
  declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: number, now: number): AccountCooldownPlan;
47
+ /**
48
+ * Seed each account's runtime quota from the persisted snapshots in
49
+ * ~/.neurolink/account-quotas.json (keyed by label). Runtime state is
50
+ * in-memory only, so without this the quota-aware ordering is blind after a
51
+ * proxy restart: all accounts tie, selection falls back to token-store
52
+ * enumeration order, and the first account served becomes self-reinforcing
53
+ * (it alone has data) — starving the others regardless of their resets.
54
+ * Never overwrites fresher in-memory quota; stale disk snapshots degrade
55
+ * gracefully because past reset timestamps are ignored by resetEpochToMs.
56
+ */
57
+ declare function seedRuntimeQuotasFromDisk(accounts: ProxyPassthroughAccount[]): Promise<void>;
47
58
  /**
48
59
  * Order accounts to MAXIMIZE quota utilization (fill-first, smart order):
49
60
  * spend the account whose window refreshes SOONEST first, so its about-to-reset
50
61
  * allowance isn't wasted, then move to accounts with longer-dated resets.
51
62
  *
52
63
  * Priority among usable accounts:
53
- * 1. soonest WEEKLY (7d) reset the scarce, use-it-or-lose-it ceiling
54
- * 2. soonest SESSION (5h) reset
55
- * 3. highest weekly utilization finish off the one closest to done
56
- * Accounts with no quota data yet keep insertion order (stable sort) and sit
57
- * after those with a known soonest reset. Cooling/rejected accounts sort last,
58
- * soonest-back-to-service first, as last resort.
64
+ * 1. no quota data yet probe first: one request reveals its windows and
65
+ * self-corrects the ordering. (Ranking unknowns last would starve them
66
+ * forever: never picked never observed never comparable.)
67
+ * 2. soonest WEEKLY (7d) reset — the scarce, use-it-or-lose-it ceiling
68
+ * 3. soonest SESSION (5h) reset
69
+ * 4. highest weekly utilization finish off the one closest to done
70
+ * 5. configured primary account, then insertion order
71
+ * Cooling/rejected accounts sort last, soonest-back-to-service first, as
72
+ * last resort.
59
73
  */
60
74
  declare function orderAccountsByQuota(accounts: ProxyPassthroughAccount[], now: number): ProxyPassthroughAccount[];
61
75
  /**
@@ -95,6 +109,8 @@ export declare const __testHooks: {
95
109
  planCooldownFor429: typeof planCooldownFor429;
96
110
  orderAccountsByQuota: typeof orderAccountsByQuota;
97
111
  resetEpochToMs: typeof resetEpochToMs;
112
+ seedRuntimeQuotasFromDisk: typeof seedRuntimeQuotasFromDisk;
113
+ getAccountRuntimeState: (key: string) => RuntimeAccountState | undefined;
98
114
  setConfiguredPrimaryAccountKey: (key: string | undefined) => void;
99
115
  getConfiguredPrimaryAccountKey: () => string | undefined;
100
116
  setPrimaryAccountIndex: (index: number) => void;
@@ -13,7 +13,7 @@ import { access, readFile } from "node:fs/promises";
13
13
  import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { buildStableClaudeCodeBillingHeader, CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_OAUTH_BETAS, getOrCreateClaudeCodeIdentity, parseClaudeCodeUserId, } from "../../auth/anthropicOAuth.js";
16
- import { parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
16
+ import { loadAccountQuotas, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
17
17
  import { buildClaudeError, ClaudeStreamSerializer, generateToolUseId, parseClaudeRequest, serializeClaudeResponse, } from "../../proxy/claudeFormat.js";
18
18
  import { buildAnthropicModelsListResponse, buildTranslationOptions, extractText, extractToolArgs, extractUsageFromStreamResult, handleTranslatedJsonRequest, handleTranslatedStreamRequest, hasTranslatedOutput, } from "../../proxy/proxyTranslationEngine.js";
19
19
  import { tracers } from "../../telemetry/tracers.js";
@@ -242,6 +242,30 @@ function maybeCoolFromQuota(state, quota, now) {
242
242
  logger.always(`[proxy] proactively cooling account (${reason}) ~${minutesUntil(clamped, now)}m from success-response quota (status rejected)`);
243
243
  }
244
244
  }
245
+ /**
246
+ * Seed each account's runtime quota from the persisted snapshots in
247
+ * ~/.neurolink/account-quotas.json (keyed by label). Runtime state is
248
+ * in-memory only, so without this the quota-aware ordering is blind after a
249
+ * proxy restart: all accounts tie, selection falls back to token-store
250
+ * enumeration order, and the first account served becomes self-reinforcing
251
+ * (it alone has data) — starving the others regardless of their resets.
252
+ * Never overwrites fresher in-memory quota; stale disk snapshots degrade
253
+ * gracefully because past reset timestamps are ignored by resetEpochToMs.
254
+ */
255
+ async function seedRuntimeQuotasFromDisk(accounts) {
256
+ try {
257
+ const persisted = await loadAccountQuotas();
258
+ for (const account of accounts) {
259
+ const state = getOrCreateRuntimeState(account.key);
260
+ if (!state.quota && persisted[account.label]) {
261
+ state.quota = persisted[account.label];
262
+ }
263
+ }
264
+ }
265
+ catch {
266
+ // Non-fatal: seeding is best-effort; ordering falls back to probe-first.
267
+ }
268
+ }
245
269
  /** Quota-aware selection is on by default; disable with
246
270
  * NEUROLINK_PROXY_QUOTA_ROUTING=off|false|0. Only affects the fill-first
247
271
  * strategy (round-robin keeps strict rotation). */
@@ -260,6 +284,7 @@ function accountSortMetrics(accountKey, now) {
260
284
  const sessionRejected = q?.sessionStatus === "rejected" && sessionReset !== undefined;
261
285
  return {
262
286
  usable: !coolingActive && !weeklyRejected && !sessionRejected,
287
+ hasQuota: !!q,
263
288
  coolingUntil: st?.coolingUntil ?? 0,
264
289
  weeklyReset: weeklyReset ?? Number.POSITIVE_INFINITY,
265
290
  sessionReset: sessionReset ?? Number.POSITIVE_INFINITY,
@@ -272,14 +297,18 @@ function accountSortMetrics(accountKey, now) {
272
297
  * allowance isn't wasted, then move to accounts with longer-dated resets.
273
298
  *
274
299
  * Priority among usable accounts:
275
- * 1. soonest WEEKLY (7d) reset the scarce, use-it-or-lose-it ceiling
276
- * 2. soonest SESSION (5h) reset
277
- * 3. highest weekly utilization finish off the one closest to done
278
- * Accounts with no quota data yet keep insertion order (stable sort) and sit
279
- * after those with a known soonest reset. Cooling/rejected accounts sort last,
280
- * soonest-back-to-service first, as last resort.
300
+ * 1. no quota data yet probe first: one request reveals its windows and
301
+ * self-corrects the ordering. (Ranking unknowns last would starve them
302
+ * forever: never picked never observed never comparable.)
303
+ * 2. soonest WEEKLY (7d) reset — the scarce, use-it-or-lose-it ceiling
304
+ * 3. soonest SESSION (5h) reset
305
+ * 4. highest weekly utilization finish off the one closest to done
306
+ * 5. configured primary account, then insertion order
307
+ * Cooling/rejected accounts sort last, soonest-back-to-service first, as
308
+ * last resort.
281
309
  */
282
310
  function orderAccountsByQuota(accounts, now) {
311
+ const primaryKey = configuredPrimaryAccountKey;
283
312
  return [...accounts].sort((a, b) => {
284
313
  const ma = accountSortMetrics(a.key, now);
285
314
  const mb = accountSortMetrics(b.key, now);
@@ -291,13 +320,22 @@ function orderAccountsByQuota(accounts, now) {
291
320
  const bu = mb.coolingUntil || Number.POSITIVE_INFINITY;
292
321
  return au - bu;
293
322
  }
323
+ if (ma.hasQuota !== mb.hasQuota) {
324
+ return ma.hasQuota ? 1 : -1;
325
+ }
294
326
  if (ma.weeklyReset !== mb.weeklyReset) {
295
327
  return ma.weeklyReset - mb.weeklyReset;
296
328
  }
297
329
  if (ma.sessionReset !== mb.sessionReset) {
298
330
  return ma.sessionReset - mb.sessionReset;
299
331
  }
300
- return mb.weeklyUsed - ma.weeklyUsed;
332
+ if (ma.weeklyUsed !== mb.weeklyUsed) {
333
+ return mb.weeklyUsed - ma.weeklyUsed;
334
+ }
335
+ if (primaryKey && (a.key === primaryKey) !== (b.key === primaryKey)) {
336
+ return a.key === primaryKey ? -1 : 1;
337
+ }
338
+ return 0;
301
339
  });
302
340
  }
303
341
  // ---------------------------------------------------------------------------
@@ -1280,6 +1318,7 @@ async function loadClaudeProxyAccounts(args) {
1280
1318
  state.lastToken = account.token;
1281
1319
  state.lastRefreshToken = account.refreshToken;
1282
1320
  }
1321
+ await seedRuntimeQuotasFromDisk(accounts);
1283
1322
  const enabledAccounts = accounts.filter((account) => {
1284
1323
  return !getOrCreateRuntimeState(account.key).permanentlyDisabled;
1285
1324
  });
@@ -3777,6 +3816,11 @@ export const __testHooks = {
3777
3816
  planCooldownFor429,
3778
3817
  orderAccountsByQuota,
3779
3818
  resetEpochToMs,
3819
+ seedRuntimeQuotasFromDisk,
3820
+ getAccountRuntimeState: (key) => {
3821
+ const state = accountRuntimeState.get(key);
3822
+ return state ? { ...state } : undefined;
3823
+ },
3780
3824
  setConfiguredPrimaryAccountKey: (key) => {
3781
3825
  configuredPrimaryAccountKey = key;
3782
3826
  },
@@ -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);