@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
428 lines (427 loc) • 18.4 kB
TypeScript
/**
* Native skills support — versioned, discoverable instruction packs
* (SOPs, playbooks, workflows) exposed to the model via progressive
* disclosure: a cheap name+description index up front, full instructions
* loaded on demand through built-in skill tools.
*
* Architecture mirrors the Hippocampus memory subsystem: a `skills` config
* on the NeuroLink constructor lazily initializes a SkillsManager, which
* auto-registers built-in tools (list_skills, plus gated mutation tools)
* and augments each generate()/stream() call with the discovery listing
* plus per-call use_skill / read_skill_resource tools.
*
* Naming: every exported type carries a `Skill` prefix to satisfy the
* `unique-type-names` ESLint rule.
*/
import type { ChatMessage } from "./conversation.js";
/** Visibility of a skill: available everywhere, or only in specific scopes. */
export type SkillScopeKind = "global" | "scoped";
/**
* Reference to an auxiliary file bundled with a skill (progressive
* disclosure level 3). Resources are read into context on demand via the
* read_skill_resource tool — a skill's SKILL.md should stay lean and point
* to resources for rarely-needed detail.
*/
export type SkillResourceRef = {
/** Path relative to the skill's directory, e.g. "references/edge-cases.md". */
path: string;
/** Size in bytes when known (listing hint only). */
size?: number;
};
/** Lifecycle status. Deletes are soft — deprecated skills stay in storage. */
export type SkillLifecycleStatus = "active" | "deprecated";
/**
* A complete skill: index metadata plus the full `instructions` body.
* `instructions` is the expensive part — it is only hydrated for matched
* skills, never included in index listings or the prompt index.
*/
export type SkillDefinition = {
/** Stable unique identifier (UUID for created skills, or derived from filename). */
id: string;
/** Machine-friendly unique name (snake_case recommended), used for matching. */
name: string;
/** Human-readable display name shown in listings. */
displayName?: string;
/** One or two sentences describing when the skill applies — the matching signal. */
description: string;
/** Full step-by-step instructions the model follows when the skill matches. */
instructions: string;
/** Domain tags for filtering (e.g. ["payments", "escalation"]). */
tags?: string[];
/** Visibility. Default: "global". */
scope?: SkillScopeKind;
/** Scope identifiers this skill is limited to when scope === "scoped" (e.g. channel/team/tenant ids). */
scopeIds?: string[];
/** Monotonic version, bumped on every approved update. Default: 1. */
version?: number;
/** Lifecycle status. Default: "active". */
status?: SkillLifecycleStatus;
/** ISO timestamp of creation. */
createdAt?: string;
/** ISO timestamp of last update. */
updatedAt?: string;
/**
* Auxiliary files bundled with the skill, readable on demand through
* read_skill_resource. Populated by stores that support resources
* (directory-layout filesystem skills, S3, Redis).
*/
resources?: SkillResourceRef[];
/** Free-form host metadata (audit fields, approval references, …). */
metadata?: Record<string, unknown>;
};
/** Lightweight index entry — everything except the instructions body. */
export type SkillIndexItem = Omit<SkillDefinition, "instructions">;
/**
* Pluggable persistence backend. NeuroLink ships memory and filesystem
* stores; hosts plug their own (S3, database, …) via the "custom" storage
* type. `index()` must be cheap relative to `get()` — it backs every
* search and prompt-index build.
*/
export type SkillStore = {
/** Fetch one skill (with instructions) by id. Null when absent. */
get(id: string): Promise<SkillDefinition | null>;
/** Create or replace a skill. */
put(skill: SkillDefinition): Promise<void>;
/** Hard-remove a skill from storage. (Soft deletes go through put().) */
delete(id: string): Promise<void>;
/** List index entries (no instructions) for all stored skills. */
index(): Promise<SkillIndexItem[]>;
/** Optional: drop any internal caches (called after mutations). */
invalidate?(): void;
/**
* Optional: fetch an auxiliary resource file bundled with a skill.
* `resourcePath` is relative to the skill (e.g. "references/forms.md").
* Null when the skill or resource is absent. Stores without resource
* support simply omit this method.
*/
getResource?(id: string, resourcePath: string): Promise<string | null>;
};
/** In-process store, optionally seeded. Good for tests and embedded use. */
export type SkillMemoryStorageConfig = {
type: "memory";
/** Initial skills to seed the store with. */
skills?: SkillDefinition[];
/**
* Resource file contents keyed by skill id → relative path.
* E.g. `{ "my-skill": { "references/forms.md": "..." } }`.
*/
resources?: Record<string, Record<string, string>>;
};
/**
* Directory-backed store. Reads three layouts:
* - `<dir>/<id>.json` — one JSON-serialized SkillDefinition per file
* - `<dir>/<name>.md` — markdown with YAML frontmatter; body = instructions
* - `<dir>/<name>/SKILL.md` — Claude-skills-style directory layout
* Mutations always write `<id>.json`; markdown sources are read-only.
*/
export type SkillFilesystemStorageConfig = {
type: "filesystem";
/** Directory containing skill files. Created on first write if absent. */
path: string;
};
/** Host-provided store implementation (e.g. curator's S3 repository). */
export type SkillCustomStorageConfig = {
type: "custom";
store: SkillStore;
};
/**
* S3-backed store. Layout: `<prefix>skills/<id>.json` per skill plus a
* `<prefix>index.json` document that is upserted on writes and rebuilt
* from a bucket listing when missing or corrupt (self-healing).
*
* Requires the optional peer `@aws-sdk/client-s3` to be installed in the
* host application (same pattern as memory's optional @juspay/hippocampus).
* Credentials default to the standard AWS provider chain when omitted.
*/
export type SkillS3StorageConfig = {
type: "s3";
bucket: string;
/** Key prefix inside the bucket. Default: "neurolink-skills/". */
prefix?: string;
region?: string;
/** Custom endpoint (MinIO, LocalStack, …). */
endpoint?: string;
/** Use path-style addressing (required by most S3-compatible stores). */
forcePathStyle?: boolean;
credentials?: {
accessKeyId: string;
secretAccessKey: string;
sessionToken?: string;
};
};
/**
* Redis-backed store using NeuroLink's pooled Redis client (`redis` v5,
* already a core dependency). One JSON value per skill under
* `<keyPrefix><id>`; the index is derived via SCAN + MGET. Skills are
* persistent — no TTL is applied.
*/
export type SkillRedisStorageConfig = {
type: "redis";
url?: string;
host?: string;
port?: number;
username?: string;
password?: string;
db?: number;
/** Key prefix. Default: "neurolink:skills:". */
keyPrefix?: string;
};
/**
* Structural surface of the lazily-required @aws-sdk/client-s3 module —
* only the pieces the S3 skill store touches (same pattern as
* HippocampusModule for the optional memory peer).
*/
export type SkillS3ModuleSurface = {
S3Client: new (config: Record<string, unknown>) => {
send: (command: unknown) => Promise<unknown>;
};
GetObjectCommand: new (input: Record<string, unknown>) => unknown;
PutObjectCommand: new (input: Record<string, unknown>) => unknown;
DeleteObjectCommand: new (input: Record<string, unknown>) => unknown;
ListObjectsV2Command: new (input: Record<string, unknown>) => unknown;
};
/** Shape of the `<prefix>index.json` document maintained by the S3 store. */
export type SkillS3IndexDocument = {
lastUpdated: string;
skills: SkillIndexItem[];
};
/** Result of a conditional (ETag) object read. */
export type SkillS3ConditionalGetResult = {
/** Object body; null when the key is absent. */
body: string | null;
/** ETag of the returned body, for the next conditional read. */
etag?: string;
/** True when the object is unchanged since the supplied ETag (no body). */
notModified?: boolean;
};
/**
* Minimal object-storage operations the S3 skill store runs on. The
* default implementation is created lazily from @aws-sdk/client-s3;
* tests and hosts with pre-built clients can inject their own.
*/
export type SkillS3ObjectOps = {
/** Fetch an object's body as a UTF-8 string. Null when the key is absent. */
getObject(key: string): Promise<string | null>;
putObject(key: string, body: string): Promise<void>;
deleteObject(key: string): Promise<void>;
/** List all object keys under a prefix (paginated internally). */
listKeys(prefix: string): Promise<string[]>;
/**
* Optional: ETag-conditional read (If-None-Match). Used for index.json
* refreshes so an unchanged index costs a 304 instead of a full download.
* Ops without it fall back to plain getObject.
*/
getObjectConditional?(key: string, etag?: string): Promise<SkillS3ConditionalGetResult>;
};
export type SkillsStorageConfig = SkillMemoryStorageConfig | SkillFilesystemStorageConfig | SkillS3StorageConfig | SkillRedisStorageConfig | SkillCustomStorageConfig;
/** Query accepted by SkillsManager.search() (programmatic + CLI search). */
export type SkillSearchQuery = {
/** Keyword matched (case-insensitive substring) against name, displayName, and description. */
query?: string;
/** Tag filter, applied on top of the keyword match. */
tag?: string;
/** Scope filter: include global skills plus skills scoped to this id. */
scopeId?: string;
/** Maximum matches to hydrate. Defaults to SkillsConfig.maxMatches. */
limit?: number;
};
/** Input for creating a skill (id/version/status/timestamps are assigned by the manager). */
export type SkillCreateInput = {
name: string;
displayName?: string;
description: string;
instructions: string;
tags?: string[];
scope?: SkillScopeKind;
scopeIds?: string[];
metadata?: Record<string, unknown>;
};
/** Patch for updating a skill. Only provided fields change; version is bumped. */
export type SkillUpdateInput = Partial<SkillCreateInput>;
/** A proposed mutation, passed to the host's onMutationRequest gate. */
export type SkillMutationAction = {
type: "create";
skill: SkillCreateInput;
requestedBy?: string;
} | {
type: "update";
skillId: string;
patch: SkillUpdateInput;
requestedBy?: string;
} | {
type: "delete";
skillId: string;
requestedBy?: string;
};
/**
* Host decision for a proposed mutation.
* - "approved": NeuroLink applies the mutation immediately.
* - "rejected": nothing is written; `reason` is surfaced to the model.
* - "pending": the host queued the action for out-of-band approval
* (e.g. a Slack maker-checker flow) and will apply it itself later;
* `reference` is surfaced to the model (e.g. an approval ticket id).
*/
export type SkillMutationDecision = {
outcome: "approved";
} | {
outcome: "rejected";
reason?: string;
} | {
outcome: "pending";
reference?: string;
};
/** Result envelope returned by SkillsManager.requestMutation(). */
export type SkillMutationResult = {
decision: SkillMutationDecision;
/** The resulting skill after an applied create/update (absent for delete/pending/rejected). */
skill?: SkillDefinition;
};
/**
* Instance-level skills configuration (NeuroLink constructor `skills` option).
* Opt-in: nothing is registered or injected unless `enabled: true`.
*/
export type SkillsConfig = {
enabled: boolean;
/** Persistence backend. Default: `{ type: "memory" }`. */
storage?: SkillsStorageConfig;
/**
* Where the skills listing (names + descriptions, never instructions)
* surfaces for model-driven discovery:
* - "tool" (default): an `<available_skills>` block embedded in the
* use_skill tool description — the Claude Code pattern. Keeps the
* host's system prompt untouched and the listing cache-stable.
* - "system-prompt": a "## Available Skills" index appended to the
* system prompt instead.
* - "none": no listing anywhere; discovery only via list_skills.
*/
discovery?: SkillDiscoveryMode;
/**
* Character budget for the "tool" discovery listing. When the full
* listing exceeds it, every description is shortened uniformly (first
* sentence, then a hard cap) so the render stays a pure function of the
* index — byte-stable across calls; names are never dropped.
* Default: 15000.
*/
listingBudgetChars?: number;
/**
* Pin activated skill instructions into session history so later turns
* replay them verbatim (byte-stable, provider-cacheable) instead of
* re-fetching the skill. Requires conversation memory + a sessionId on
* the call. Default: true.
*/
sessionPersistence?: boolean;
/** Maximum skills hydrated (with instructions) per search. Default: 5. */
maxMatches?: number;
/**
* Maximum entries rendered by the "system-prompt" discovery mode before
* truncation. Default: 50. The "tool" mode is bounded by
* listingBudgetChars instead and never drops entries.
*/
promptIndexMaxItems?: number;
/** Index cache TTL in milliseconds. Default: 30000. 0 disables caching. */
indexCacheTtlMs?: number;
/** Default scope filter applied when a call/tool provides none. */
defaultScopeId?: string;
/**
* Register skill_create / skill_update / skill_delete tools so the model
* can propose skill changes. Default: false. Combine with
* `onMutationRequest` to gate writes behind host approval.
*/
allowMutations?: boolean;
/**
* Host approval gate invoked before any mutation is applied. When absent
* and allowMutations is true, mutations apply directly. Errors thrown
* here reject the mutation (fail closed for writes).
*/
onMutationRequest?: (action: SkillMutationAction) => Promise<SkillMutationDecision>;
};
/** Where the skills discovery listing surfaces. See SkillsConfig.discovery. */
export type SkillDiscoveryMode = "tool" | "system-prompt" | "none";
/**
* One activated skill in a session: which skill, at which version, when.
* Sessions pin the version active at activation time — a mid-session skill
* update never mutates instructions the model has already loaded.
*/
export type SkillActivationRecord = {
skillId: string;
name: string;
version: number;
/** ISO timestamp of activation. */
activatedAt: string;
};
/**
* Structural view of the per-session activation tracker consumed by the
* skill tools factory.
*/
export type SkillSessionStateLike = {
isActive: (sessionId: string, skillId: string, name: string) => boolean;
getActivation: (sessionId: string, skillId: string, name?: string) => SkillActivationRecord | undefined;
recordActivation: (sessionId: string, skill: SkillDefinition) => ChatMessage;
hydrate: (sessionId: string, storedMessages: ChatMessage[]) => void;
};
/**
* Structural view of SkillsManager consumed by the skill tools factory —
* keeps skillTools.ts decoupled from the concrete manager class.
*/
export type SkillsManagerLike = {
search: (query: SkillSearchQuery) => Promise<SkillDefinition[]>;
list: (scopeId?: string) => Promise<SkillIndexItem[]>;
get: (idOrName: string) => Promise<SkillDefinition | null>;
getResource: (idOrName: string, resourcePath: string) => Promise<string | null>;
sessions: SkillSessionStateLike;
requestMutation: (action: SkillMutationAction) => Promise<SkillMutationResult>;
};
/**
* Per-call context bound into the use_skill / read_skill_resource tools at
* injection time (prepareGenerate/prepareStream). The sessionId is captured
* by closure so activation state is tracked without relying on runtime tool
* context plumbing.
*/
export type SkillCallToolsContext = {
/** Session the call belongs to; absent → activation state is per-turn only. */
sessionId?: string;
/** Scope filter applied when resolving skills for this call. */
scopeId?: string;
/**
* Pin activated instructions into session history after the turn.
* Mirrors SkillsConfig.sessionPersistence resolved for this call.
*/
sessionPersistence: boolean;
/** Discovery mode resolved for this call — shapes the use_skill description. */
discovery: SkillDiscoveryMode;
/** Rendered `<available_skills>` block for "tool" discovery; null when empty. */
listing?: string | null;
/**
* Stored session history loader used to hydrate activation state before
* every dedup check (restart/multi-instance/failed-persistence safety).
* Invoked once per use_skill / read_skill_resource attempt.
*/
getStoredMessages?: (sessionId: string) => Promise<ChatMessage[]>;
};
/** Options for the createSkillTools factory. */
export type SkillToolsOptions = {
/** Include skill_create / skill_update / skill_delete. Default: false. */
allowMutations?: boolean;
};
/**
* Per-call skills control on generate()/stream(). Only effective when the
* instance was constructed with skills enabled; per-call wins over instance
* config (same precedence convention as per-call credentials).
*/
export type SkillsCallOptions = {
/** Master toggle for this call (listing + per-call tools). Default: true. */
enabled?: boolean;
/** Per-call override of SkillsConfig.discovery. */
discovery?: SkillDiscoveryMode;
/** Scope filter for the listing and skill resolution on this call. Overrides defaultScopeId. */
scopeId?: string;
/** Restrict the listing to skills carrying at least one of these tags. */
tags?: string[];
/**
* Skill names to activate at the start of this call: their full
* instructions are injected up front (and pinned to the session when
* sessionPersistence is on), without waiting for the model to invoke
* use_skill. Already-active skills are skipped.
*/
preload?: string[];
};