@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
281 lines (280 loc) • 12.4 kB
JavaScript
/**
* SkillsManager — orchestrates the skill store, cached index, index-first
* search (hydrating instructions only for matches), prompt-index rendering,
* and the mutation gate.
*
* Read paths fail open (errors surface as empty results + a warn log);
* write paths fail closed (errors reject the mutation).
*/
import { randomUUID } from "node:crypto";
import { logger } from "../utils/logger.js";
import { filterSkillIndex, formatSkillsPromptIndex, renderSkillListing, sortSkillIndex, } from "./skillMatcher.js";
import { SkillSessionTracker } from "./skillSessionTracker.js";
import { createSkillStore } from "./skillStores.js";
const DEFAULT_MAX_MATCHES = 5;
const DEFAULT_PROMPT_INDEX_MAX_ITEMS = 50;
const DEFAULT_INDEX_CACHE_TTL_MS = 30_000;
const DEFAULT_LISTING_BUDGET_CHARS = 15_000;
export class SkillsManager {
config;
store;
cachedIndex = null;
cachedIndexAt = 0;
/** Serializes writes within this process — see requestMutation(). */
mutationQueue = Promise.resolve();
/** Per-session activation state (pinned skills). */
sessions = new SkillSessionTracker();
constructor(config) {
this.config = config;
this.store = createSkillStore(config.storage);
}
/**
* Cached index read, sorted by name. TTL 0 disables caching. Sorting
* here (not per render) keeps every downstream listing byte-stable
* regardless of store enumeration order.
*/
async getIndex(forceRefresh = false) {
const ttl = this.config.indexCacheTtlMs ?? DEFAULT_INDEX_CACHE_TTL_MS;
const fresh = this.cachedIndex !== null &&
ttl > 0 &&
Date.now() - this.cachedIndexAt < ttl;
if (fresh && !forceRefresh && this.cachedIndex) {
return this.cachedIndex;
}
const index = sortSkillIndex(await this.store.index());
this.cachedIndex = index;
this.cachedIndexAt = Date.now();
return index;
}
invalidateIndex() {
this.cachedIndex = null;
this.store.invalidate?.();
}
/**
* Index-first search: filter the cached index, hydrate only the matched
* entries (max `limit`) with instructions. Cost: one cached index read +
* N_matched store gets.
*/
async search(query) {
const limit = query.limit ?? this.config.maxMatches ?? DEFAULT_MAX_MATCHES;
const scopeId = query.scopeId ?? this.config.defaultScopeId;
const index = await this.getIndex();
const matched = filterSkillIndex(index, {
...query,
...(scopeId !== undefined ? { scopeId } : {}),
limit,
});
const hydrated = await Promise.all(matched.map((item) => this.store.get(item.id)));
return hydrated.filter((s) => s !== null);
}
/** Index entries only — no instructions. For discovery/listing. */
async list(scopeId) {
const effectiveScopeId = scopeId ?? this.config.defaultScopeId;
const index = await this.getIndex();
return filterSkillIndex(index, {
...(effectiveScopeId !== undefined ? { scopeId: effectiveScopeId } : {}),
});
}
/** Fetch one skill by id, falling back to name lookup. */
async get(idOrName) {
const byId = await this.store.get(idOrName);
if (byId) {
return byId;
}
const index = await this.getIndex();
// Prefer an active skill when resolving by name: a soft-deleted skill and a
// later same-named active skill can coexist in the index, so a bare
// name-find would resolve non-deterministically to the stale deprecated one
// across store backends. Fall back to any match only when no active skill
// carries the name.
//
// Matched case-insensitively to agree with assertNameAvailable, which
// enforces uniqueness that way: names differing only in case cannot
// coexist, so a case-sensitive lookup could only fail to find a skill that
// is definitively there (`get("DEPLOY")` returned null for "deploy").
const wanted = idOrName.toLowerCase();
const entry = index.find((item) => item.name.toLowerCase() === wanted &&
(item.status ?? "active") === "active") ?? index.find((item) => item.name.toLowerCase() === wanted);
return entry ? this.store.get(entry.id) : null;
}
/**
* Render the system-prompt skills index for one call, or null when
* nothing is visible. Never includes instructions.
*/
async buildPromptIndex(options) {
const items = await this.visibleItems(options);
return formatSkillsPromptIndex(items, this.config.promptIndexMaxItems ?? DEFAULT_PROMPT_INDEX_MAX_ITEMS);
}
/**
* Render the `<available_skills>` block for the use_skill tool
* description ("tool" discovery mode), or null when nothing is visible.
* Bounded by listingBudgetChars; entries are never dropped.
*/
async buildToolListing(options) {
const items = await this.visibleItems(options);
return renderSkillListing(items, this.config.listingBudgetChars ?? DEFAULT_LISTING_BUDGET_CHARS);
}
/** Visible (active, scope- and tag-filtered) index entries for one call. */
async visibleItems(options) {
let items = await this.list(options?.scopeId);
if (options?.tags && options.tags.length > 0) {
const wanted = options.tags.map((t) => t.toLowerCase());
items = items.filter((item) => (item.tags ?? []).some((t) => wanted.includes(t.toLowerCase())));
}
return items;
}
/**
* Read an auxiliary resource file bundled with a skill. Paths are
* relative to the skill; traversal segments are rejected. Null when the
* skill, the resource, or store resource support is absent.
*/
async getResource(idOrName, resourcePath) {
const normalized = resourcePath.replace(/\\/g, "/");
if (normalized.startsWith("/") ||
normalized.split("/").some((segment) => segment === "..")) {
throw new Error(`Invalid resource path "${resourcePath}" — must be relative to the skill directory`);
}
if (!this.store.getResource) {
return null;
}
const skill = await this.get(idOrName);
if (!skill) {
return null;
}
return this.store.getResource(skill.id, normalized);
}
/**
* Whether skill create/update/delete is enabled on this instance. Gates the
* LLM-facing `skill_*` tools (registration) and the server REST mutation
* routes. Direct programmatic `requestMutation` calls are intentionally not
* gated, so a host can still seed skills at startup.
*/
get mutationsAllowed() {
return this.config.allowMutations ?? false;
}
/**
* Gate a proposed mutation through the host's onMutationRequest hook,
* then apply it when approved. No hook configured means direct apply
* (the tools themselves are already gated by allowMutations).
*/
async requestMutation(action) {
let decision = { outcome: "approved" };
if (this.config.onMutationRequest) {
decision = await this.config.onMutationRequest(action);
}
if (decision.outcome !== "approved") {
return { decision };
}
// Serialize writes within this process so the name-uniqueness check and
// the store write can never interleave across concurrent mutations.
// Cross-process races remain possible with plain-object stores (S3,
// Redis) — hosts needing strict global uniqueness should serialize via
// their onMutationRequest gate; the S3 index additionally self-heals.
const run = this.mutationQueue.then(() => this.applyMutation(action));
this.mutationQueue = run.catch(() => undefined);
const skill = await run;
return { decision, ...(skill ? { skill } : {}) };
}
async applyMutation(action) {
const now = new Date().toISOString();
if (action.type === "create") {
await this.assertNameAvailable(action.skill.name);
const skill = {
id: randomUUID(),
version: 1,
status: "active",
createdAt: now,
updatedAt: now,
...action.skill,
};
assertScopeConsistent(skill);
await this.store.put(skill);
this.invalidateIndex();
logger.info("[SkillsManager] Skill created", {
skillId: skill.id,
name: skill.name,
});
return skill;
}
if (action.type === "update") {
const existing = await this.get(action.skillId);
if (!existing) {
throw new Error(`Skill "${action.skillId}" not found`);
}
if (action.patch.name && action.patch.name !== existing.name) {
await this.assertNameAvailable(action.patch.name, existing.id);
}
const updated = {
...existing,
...definedFields(action.patch),
id: existing.id,
version: (existing.version ?? 1) + 1,
updatedAt: now,
};
assertScopeConsistent(updated);
await this.store.put(updated);
this.invalidateIndex();
logger.info("[SkillsManager] Skill updated", {
skillId: updated.id,
version: updated.version,
});
return updated;
}
// delete — soft: mark deprecated so the skill drops out of active
// listings but stays in storage for audit/undo.
const existing = await this.get(action.skillId);
if (!existing) {
throw new Error(`Skill "${action.skillId}" not found`);
}
if (existing.status === "deprecated") {
throw new Error(`Skill "${existing.name}" is already deleted`);
}
const deprecated = {
...existing,
status: "deprecated",
updatedAt: now,
};
await this.store.put(deprecated);
this.invalidateIndex();
logger.info("[SkillsManager] Skill deprecated", {
skillId: existing.id,
name: existing.name,
});
return deprecated;
}
async assertNameAvailable(name, excludeId) {
const index = await this.getIndex(true);
// Deliberately NOT filtered to active (#1139). Soft-delete only flips
// status to "deprecated" — the entry stays in the index, so allowing a new
// skill to take the name left two entries sharing it. Every name-based
// lookup (CLI `skills show/delete <name>`, the skill_update/skill_delete
// tools, the `:id`-or-name REST routes) then had to guess which one the
// caller meant, and iteration order differs across store backends.
//
// A deprecated skill's name therefore stays reserved. Reusing it requires
// hard-deleting the old skill first, which is the explicit choice the
// ambiguity demands.
const clash = index.find((item) => item.id !== excludeId && item.name.toLowerCase() === name.toLowerCase());
if (clash) {
const suffix = (clash.status ?? "active") === "active"
? ""
: ` (that name belongs to a deprecated skill, id ${clash.id}; ` +
`remove it before reusing the name)`;
throw new Error(`A skill named "${name}" already exists${suffix}`);
}
}
}
/**
* A scoped skill without scopeIds can never match anything — reject the
* write instead of persisting an unmatchable skill. Validated post-merge so
* both create and patch-style update paths are covered.
*/
function assertScopeConsistent(skill) {
if (skill.scope === "scoped" && (skill.scopeIds ?? []).length === 0) {
throw new Error('A skill with scope "scoped" must have at least one entry in scopeIds');
}
}
/** Shallow-copy only the defined fields of a patch (undefined must not overwrite). */
function definedFields(patch) {
return Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined));
}