@juspay/neurolink 9.85.1 → 9.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -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/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/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
|
@@ -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
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
19
|
import { logger } from "../utils/logger.js";
|
|
20
|
-
import { toSkillIndexItem } from "./skillMatcher.js";
|
|
20
|
+
import { isSafeSkillResourcePath, toSkillIndexItem } from "./skillMatcher.js";
|
|
21
21
|
const lazyRequire = createRequire(import.meta.url);
|
|
22
22
|
let cachedS3Module;
|
|
23
23
|
function loadS3Module() {
|
|
@@ -97,6 +97,32 @@ function createDefaultOps(config) {
|
|
|
97
97
|
} while (continuationToken);
|
|
98
98
|
return keys;
|
|
99
99
|
},
|
|
100
|
+
async getObjectConditional(key, etag) {
|
|
101
|
+
try {
|
|
102
|
+
const response = (await client.send(new mod.GetObjectCommand({
|
|
103
|
+
Bucket: bucket,
|
|
104
|
+
Key: key,
|
|
105
|
+
...(etag ? { IfNoneMatch: etag } : {}),
|
|
106
|
+
})));
|
|
107
|
+
const body = (await response.Body?.transformToString()) ?? null;
|
|
108
|
+
return {
|
|
109
|
+
body,
|
|
110
|
+
...(response.ETag ? { etag: response.ETag } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
const name = error.name;
|
|
115
|
+
const status = error
|
|
116
|
+
.$metadata?.httpStatusCode;
|
|
117
|
+
if (status === 304 || name === "304" || name === "NotModified") {
|
|
118
|
+
return { body: null, notModified: true };
|
|
119
|
+
}
|
|
120
|
+
if (name === "NoSuchKey" || name === "NotFound") {
|
|
121
|
+
return { body: null };
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
},
|
|
100
126
|
};
|
|
101
127
|
}
|
|
102
128
|
export class S3SkillStore {
|
|
@@ -104,6 +130,9 @@ export class S3SkillStore {
|
|
|
104
130
|
injectedOps;
|
|
105
131
|
prefix;
|
|
106
132
|
ops = null;
|
|
133
|
+
/** Last parsed index + its ETag, revalidated with If-None-Match reads. */
|
|
134
|
+
cachedIndexDoc = null;
|
|
135
|
+
cachedIndexEtag;
|
|
107
136
|
constructor(config,
|
|
108
137
|
/** Test/host seam — omit to build ops from @aws-sdk/client-s3 lazily. */
|
|
109
138
|
injectedOps) {
|
|
@@ -113,6 +142,10 @@ export class S3SkillStore {
|
|
|
113
142
|
this.prefix =
|
|
114
143
|
rawPrefix === "" || rawPrefix.endsWith("/") ? rawPrefix : `${rawPrefix}/`;
|
|
115
144
|
}
|
|
145
|
+
invalidate() {
|
|
146
|
+
this.cachedIndexDoc = null;
|
|
147
|
+
this.cachedIndexEtag = undefined;
|
|
148
|
+
}
|
|
116
149
|
getOps() {
|
|
117
150
|
if (!this.ops) {
|
|
118
151
|
this.ops = this.injectedOps ?? createDefaultOps(this.config);
|
|
@@ -125,6 +158,16 @@ export class S3SkillStore {
|
|
|
125
158
|
indexKey() {
|
|
126
159
|
return `${this.prefix}index.json`;
|
|
127
160
|
}
|
|
161
|
+
/** Resources live beside the skill object: <prefix>skills/<id>/<path>. */
|
|
162
|
+
resourceKey(id, resourcePath) {
|
|
163
|
+
return `${this.prefix}skills/${id}/${resourcePath}`;
|
|
164
|
+
}
|
|
165
|
+
async getResource(id, resourcePath) {
|
|
166
|
+
if (!isSafeSkillResourcePath(resourcePath)) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return this.getOps().getObject(this.resourceKey(id, resourcePath));
|
|
170
|
+
}
|
|
128
171
|
async get(id) {
|
|
129
172
|
const body = await this.getOps().getObject(this.skillKey(id));
|
|
130
173
|
if (!body) {
|
|
@@ -169,18 +212,42 @@ export class S3SkillStore {
|
|
|
169
212
|
async writeIndex(index) {
|
|
170
213
|
index.lastUpdated = new Date().toISOString();
|
|
171
214
|
await this.getOps().putObject(this.indexKey(), JSON.stringify(index, null, 2));
|
|
215
|
+
// The write changed the object's ETag; keep the doc, revalidate next read.
|
|
216
|
+
this.cachedIndexDoc = index;
|
|
217
|
+
this.cachedIndexEtag = undefined;
|
|
172
218
|
}
|
|
173
219
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
220
|
+
* Raw index.json read. Returns the body (with its ETag when available),
|
|
221
|
+
* null body when the object is absent, or notModified when a
|
|
222
|
+
* conditional read reported the cached copy unchanged.
|
|
223
|
+
*/
|
|
224
|
+
async readIndexObject(ops) {
|
|
225
|
+
if (ops.getObjectConditional) {
|
|
226
|
+
return ops.getObjectConditional(this.indexKey(), this.cachedIndexDoc ? this.cachedIndexEtag : undefined);
|
|
227
|
+
}
|
|
228
|
+
return { body: await ops.getObject(this.indexKey()) };
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Read index.json (ETag-revalidated when the ops support conditional
|
|
232
|
+
* reads — an unchanged index costs a 304, not a download); when missing
|
|
233
|
+
* or unparsable, rebuild it from a listing of the skills/ prefix and
|
|
234
|
+
* persist the rebuilt document (self-heal).
|
|
176
235
|
*/
|
|
177
236
|
async readOrRebuildIndex() {
|
|
178
237
|
const ops = this.getOps();
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
238
|
+
const read = await this.readIndexObject(ops);
|
|
239
|
+
if (read.notModified && this.cachedIndexDoc) {
|
|
240
|
+
return this.cachedIndexDoc;
|
|
241
|
+
}
|
|
242
|
+
if (read.body) {
|
|
181
243
|
try {
|
|
182
|
-
const parsed = JSON.parse(
|
|
244
|
+
const parsed = JSON.parse(read.body);
|
|
183
245
|
if (Array.isArray(parsed.skills)) {
|
|
246
|
+
this.cachedIndexDoc = parsed;
|
|
247
|
+
// Cache the ETag only for a body that parsed: pairing a corrupt
|
|
248
|
+
// object's ETag with the previous good doc would make every
|
|
249
|
+
// later conditional read 304 into permanently stale data.
|
|
250
|
+
this.cachedIndexEtag = read.etag;
|
|
184
251
|
return parsed;
|
|
185
252
|
}
|
|
186
253
|
}
|
|
@@ -189,6 +256,7 @@ export class S3SkillStore {
|
|
|
189
256
|
error: error instanceof Error ? error.message : String(error),
|
|
190
257
|
});
|
|
191
258
|
}
|
|
259
|
+
this.cachedIndexEtag = undefined;
|
|
192
260
|
}
|
|
193
261
|
const skillsPrefix = `${this.prefix}skills/`;
|
|
194
262
|
const keys = await ops.listKeys(skillsPrefix);
|
|
@@ -214,6 +282,10 @@ export class S3SkillStore {
|
|
|
214
282
|
lastUpdated: new Date().toISOString(),
|
|
215
283
|
skills,
|
|
216
284
|
};
|
|
285
|
+
// Cache the rebuilt doc even when persistence below fails, so reads
|
|
286
|
+
// don't fall back to a stale pre-rebuild copy.
|
|
287
|
+
this.cachedIndexDoc = rebuilt;
|
|
288
|
+
this.cachedIndexEtag = undefined;
|
|
217
289
|
try {
|
|
218
290
|
await this.writeIndex(rebuilt);
|
|
219
291
|
logger.info("[SkillStoreS3] Rebuilt skills index", {
|
|
@@ -11,26 +11,40 @@ import type { SkillDefinition, SkillIndexItem, SkillStore, SkillsStorageConfig }
|
|
|
11
11
|
/** In-process store backed by a Map. */
|
|
12
12
|
export declare class InMemorySkillStore implements SkillStore {
|
|
13
13
|
private skills;
|
|
14
|
-
|
|
14
|
+
/** skill id → relative path → content. */
|
|
15
|
+
private resources;
|
|
16
|
+
constructor(seed?: SkillDefinition[], resources?: Record<string, Record<string, string>>);
|
|
15
17
|
get(id: string): Promise<SkillDefinition | null>;
|
|
16
18
|
put(skill: SkillDefinition): Promise<void>;
|
|
17
19
|
delete(id: string): Promise<void>;
|
|
18
20
|
index(): Promise<SkillIndexItem[]>;
|
|
21
|
+
getResource(id: string, resourcePath: string): Promise<string | null>;
|
|
19
22
|
}
|
|
20
23
|
/**
|
|
21
24
|
* Directory-backed store. Read layouts:
|
|
22
25
|
* - `<dir>/<id>.json` — JSON SkillDefinition (mutable)
|
|
23
26
|
* - `<dir>/<name>.md` — frontmatter markdown (read-only source)
|
|
24
|
-
* - `<dir>/<name>/SKILL.md` — Claude-skills directory layout (read-only source)
|
|
27
|
+
* - `<dir>/<name>/SKILL.md` — Claude-skills directory layout (read-only source);
|
|
28
|
+
* sibling files become on-demand resources (read_skill_resource)
|
|
25
29
|
* Mutations always write `<id>.json`; a JSON file shadows a markdown skill
|
|
26
30
|
* with the same id, so updating a markdown-sourced skill "copies up" to JSON.
|
|
27
31
|
*/
|
|
28
32
|
export declare class FileSystemSkillStore implements SkillStore {
|
|
29
33
|
private readonly baseDir;
|
|
30
34
|
private cache;
|
|
35
|
+
/** skill id → directory, for skills loaded from the SKILL.md layout. */
|
|
36
|
+
private skillDirs;
|
|
31
37
|
constructor(baseDir: string);
|
|
32
38
|
invalidate(): void;
|
|
33
39
|
get(id: string): Promise<SkillDefinition | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Resources exist only for directory-layout skills (`<name>/SKILL.md`) —
|
|
42
|
+
* every sibling file of SKILL.md is addressable by its relative path.
|
|
43
|
+
* The REAL path must stay inside the skill directory: lexical
|
|
44
|
+
* containment alone would follow a symlink planted inside the skill dir
|
|
45
|
+
* to anywhere on the host.
|
|
46
|
+
*/
|
|
47
|
+
getResource(id: string, resourcePath: string): Promise<string | null>;
|
|
34
48
|
put(skill: SkillDefinition): Promise<void>;
|
|
35
49
|
delete(id: string): Promise<void>;
|
|
36
50
|
index(): Promise<SkillIndexItem[]>;
|