@juspay/neurolink 9.85.1 → 9.86.0

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