@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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +370 -362
- package/dist/context/contextCompactor.js +16 -2
- package/dist/context/stages/slidingWindowTruncator.js +76 -30
- package/dist/core/conversationMemoryManager.js +13 -2
- package/dist/core/redisConversationMemoryManager.js +10 -1
- package/dist/lib/context/contextCompactor.js +16 -2
- package/dist/lib/context/stages/slidingWindowTruncator.js +76 -30
- package/dist/lib/core/conversationMemoryManager.js +13 -2
- package/dist/lib/core/redisConversationMemoryManager.js +10 -1
- package/dist/lib/neurolink.d.ts +31 -6
- package/dist/lib/neurolink.js +163 -33
- package/dist/lib/proxy/accountQuota.d.ts +5 -0
- package/dist/lib/proxy/accountQuota.js +8 -1
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +22 -6
- package/dist/lib/server/routes/claudeProxyRoutes.js +52 -8
- package/dist/lib/skills/skillMatcher.d.ts +33 -4
- package/dist/lib/skills/skillMatcher.js +81 -6
- package/dist/lib/skills/skillSessionTracker.d.ts +52 -0
- package/dist/lib/skills/skillSessionTracker.js +150 -0
- package/dist/lib/skills/skillStoreRedis.d.ts +8 -0
- package/dist/lib/skills/skillStoreRedis.js +18 -2
- package/dist/lib/skills/skillStoreS3.d.ts +17 -2
- package/dist/lib/skills/skillStoreS3.js +78 -6
- package/dist/lib/skills/skillStores.d.ts +16 -2
- package/dist/lib/skills/skillStores.js +94 -5
- package/dist/lib/skills/skillTools.d.ts +26 -10
- package/dist/lib/skills/skillTools.js +190 -79
- package/dist/lib/skills/skillsManager.d.ts +25 -1
- package/dist/lib/skills/skillsManager.js +46 -4
- package/dist/lib/types/config.d.ts +5 -5
- package/dist/lib/types/conversation.d.ts +27 -0
- package/dist/lib/types/skills.d.ts +145 -14
- package/dist/lib/types/skills.js +3 -3
- package/dist/lib/utils/conversationMemory.d.ts +1 -1
- package/dist/lib/utils/conversationMemory.js +15 -2
- package/dist/neurolink.d.ts +31 -6
- package/dist/neurolink.js +163 -33
- package/dist/proxy/accountQuota.d.ts +5 -0
- package/dist/proxy/accountQuota.js +8 -1
- package/dist/server/routes/claudeProxyRoutes.d.ts +22 -6
- package/dist/server/routes/claudeProxyRoutes.js +52 -8
- package/dist/skills/skillMatcher.d.ts +33 -4
- package/dist/skills/skillMatcher.js +81 -6
- package/dist/skills/skillSessionTracker.d.ts +52 -0
- package/dist/skills/skillSessionTracker.js +149 -0
- package/dist/skills/skillStoreRedis.d.ts +8 -0
- package/dist/skills/skillStoreRedis.js +18 -2
- package/dist/skills/skillStoreS3.d.ts +17 -2
- package/dist/skills/skillStoreS3.js +78 -6
- package/dist/skills/skillStores.d.ts +16 -2
- package/dist/skills/skillStores.js +94 -5
- package/dist/skills/skillTools.d.ts +26 -10
- package/dist/skills/skillTools.js +190 -79
- package/dist/skills/skillsManager.d.ts +25 -1
- package/dist/skills/skillsManager.js +46 -4
- package/dist/types/config.d.ts +5 -5
- package/dist/types/conversation.d.ts +27 -0
- package/dist/types/skills.d.ts +145 -14
- package/dist/types/skills.js +3 -3
- package/dist/utils/conversationMemory.d.ts +1 -1
- package/dist/utils/conversationMemory.js +15 -2
- package/package.json +1 -1
package/dist/lib/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 (
|
|
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
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
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
|
|
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
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
|
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:
|
|
3614
|
-
//
|
|
3615
|
-
await this.
|
|
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:
|
|
7155
|
-
//
|
|
7156
|
-
await this.
|
|
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:
|
|
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.
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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.
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
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
|
|
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);
|