@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
|
@@ -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);
|
|
@@ -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,149 @@
|
|
|
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
|
+
}
|
|
@@ -9,11 +9,19 @@
|
|
|
9
9
|
import type { SkillDefinition, SkillIndexItem, SkillRedisStorageConfig, SkillStore } from "../types/index.js";
|
|
10
10
|
export declare class RedisSkillStore implements SkillStore {
|
|
11
11
|
private readonly keyPrefix;
|
|
12
|
+
/**
|
|
13
|
+
* Resources live under `<keyPrefix>__resources__:` — a reserved segment
|
|
14
|
+
* inside the skill prefix, explicitly excluded from the index SCAN so
|
|
15
|
+
* resource values are never mistaken for skills, whatever the
|
|
16
|
+
* configured keyPrefix looks like.
|
|
17
|
+
*/
|
|
18
|
+
private readonly resourcePrefix;
|
|
12
19
|
private readonly normalizedConfig;
|
|
13
20
|
private clientPromise;
|
|
14
21
|
constructor(config: SkillRedisStorageConfig);
|
|
15
22
|
private getClient;
|
|
16
23
|
private skillKey;
|
|
24
|
+
getResource(id: string, resourcePath: string): Promise<string | null>;
|
|
17
25
|
get(id: string): Promise<SkillDefinition | null>;
|
|
18
26
|
put(skill: SkillDefinition): Promise<void>;
|
|
19
27
|
delete(id: string): Promise<void>;
|
|
@@ -8,14 +8,22 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { getNormalizedConfig, getPooledRedisClient, scanKeys, } from "../utils/redis.js";
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
|
-
import { toSkillIndexItem } from "./skillMatcher.js";
|
|
11
|
+
import { isSafeSkillResourcePath, toSkillIndexItem } from "./skillMatcher.js";
|
|
12
12
|
const DEFAULT_KEY_PREFIX = "neurolink:skills:";
|
|
13
13
|
export class RedisSkillStore {
|
|
14
14
|
keyPrefix;
|
|
15
|
+
/**
|
|
16
|
+
* Resources live under `<keyPrefix>__resources__:` — a reserved segment
|
|
17
|
+
* inside the skill prefix, explicitly excluded from the index SCAN so
|
|
18
|
+
* resource values are never mistaken for skills, whatever the
|
|
19
|
+
* configured keyPrefix looks like.
|
|
20
|
+
*/
|
|
21
|
+
resourcePrefix;
|
|
15
22
|
normalizedConfig;
|
|
16
23
|
clientPromise = null;
|
|
17
24
|
constructor(config) {
|
|
18
25
|
this.keyPrefix = config.keyPrefix ?? DEFAULT_KEY_PREFIX;
|
|
26
|
+
this.resourcePrefix = `${this.keyPrefix}__resources__:`;
|
|
19
27
|
this.normalizedConfig = getNormalizedConfig({
|
|
20
28
|
...(config.url ? { url: config.url } : {}),
|
|
21
29
|
...(config.host ? { host: config.host } : {}),
|
|
@@ -39,6 +47,14 @@ export class RedisSkillStore {
|
|
|
39
47
|
skillKey(id) {
|
|
40
48
|
return `${this.keyPrefix}${id}`;
|
|
41
49
|
}
|
|
50
|
+
async getResource(id, resourcePath) {
|
|
51
|
+
if (!isSafeSkillResourcePath(resourcePath)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const client = await this.getClient();
|
|
55
|
+
const raw = await client.get(`${this.resourcePrefix}${id}:${resourcePath}`);
|
|
56
|
+
return raw ? String(raw) : null;
|
|
57
|
+
}
|
|
42
58
|
async get(id) {
|
|
43
59
|
const client = await this.getClient();
|
|
44
60
|
const raw = await client.get(this.skillKey(id));
|
|
@@ -55,7 +71,7 @@ export class RedisSkillStore {
|
|
|
55
71
|
}
|
|
56
72
|
async index() {
|
|
57
73
|
const client = await this.getClient();
|
|
58
|
-
const keys = await scanKeys(client, `${this.keyPrefix}*`);
|
|
74
|
+
const keys = (await scanKeys(client, `${this.keyPrefix}*`)).filter((key) => !key.startsWith(this.resourcePrefix));
|
|
59
75
|
if (keys.length === 0) {
|
|
60
76
|
return [];
|
|
61
77
|
}
|
|
@@ -22,20 +22,35 @@ export declare class S3SkillStore implements SkillStore {
|
|
|
22
22
|
private readonly injectedOps?;
|
|
23
23
|
private readonly prefix;
|
|
24
24
|
private ops;
|
|
25
|
+
/** Last parsed index + its ETag, revalidated with If-None-Match reads. */
|
|
26
|
+
private cachedIndexDoc;
|
|
27
|
+
private cachedIndexEtag;
|
|
25
28
|
constructor(config: SkillS3StorageConfig,
|
|
26
29
|
/** Test/host seam — omit to build ops from @aws-sdk/client-s3 lazily. */
|
|
27
30
|
injectedOps?: SkillS3ObjectOps | undefined);
|
|
31
|
+
invalidate(): void;
|
|
28
32
|
private getOps;
|
|
29
33
|
private skillKey;
|
|
30
34
|
private indexKey;
|
|
35
|
+
/** Resources live beside the skill object: <prefix>skills/<id>/<path>. */
|
|
36
|
+
private resourceKey;
|
|
37
|
+
getResource(id: string, resourcePath: string): Promise<string | null>;
|
|
31
38
|
get(id: string): Promise<SkillDefinition | null>;
|
|
32
39
|
put(skill: SkillDefinition): Promise<void>;
|
|
33
40
|
delete(id: string): Promise<void>;
|
|
34
41
|
index(): Promise<SkillIndexItem[]>;
|
|
35
42
|
private writeIndex;
|
|
36
43
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
44
|
+
* Raw index.json read. Returns the body (with its ETag when available),
|
|
45
|
+
* null body when the object is absent, or notModified when a
|
|
46
|
+
* conditional read reported the cached copy unchanged.
|
|
47
|
+
*/
|
|
48
|
+
private readIndexObject;
|
|
49
|
+
/**
|
|
50
|
+
* Read index.json (ETag-revalidated when the ops support conditional
|
|
51
|
+
* reads — an unchanged index costs a 304, not a download); when missing
|
|
52
|
+
* or unparsable, rebuild it from a listing of the skills/ prefix and
|
|
53
|
+
* persist the rebuilt document (self-heal).
|
|
39
54
|
*/
|
|
40
55
|
private readOrRebuildIndex;
|
|
41
56
|
}
|