/** * 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; }; /** Lightweight index entry — everything except the instructions body. */ export type SkillIndexItem = Omit; /** * 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; /** Create or replace a skill. */ put(skill: SkillDefinition): Promise; /** Hard-remove a skill from storage. (Soft deletes go through put().) */ delete(id: string): Promise; /** List index entries (no instructions) for all stored skills. */ index(): Promise; /** 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; }; /** 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>; }; /** * Directory-backed store. Reads three layouts: * - `/.json` — one JSON-serialized SkillDefinition per file * - `/.md` — markdown with YAML frontmatter; body = instructions * - `//SKILL.md` — Claude-skills-style directory layout * Mutations always write `.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: `skills/.json` per skill plus a * `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 * ``; 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) => { send: (command: unknown) => Promise; }; GetObjectCommand: new (input: Record) => unknown; PutObjectCommand: new (input: Record) => unknown; DeleteObjectCommand: new (input: Record) => unknown; ListObjectsV2Command: new (input: Record) => unknown; }; /** Shape of the `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; putObject(key: string, body: string): Promise; deleteObject(key: string): Promise; /** List all object keys under a prefix (paginated internally). */ listKeys(prefix: string): Promise; /** * 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; }; 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; }; /** Patch for updating a skill. Only provided fields change; version is bumped. */ export type SkillUpdateInput = Partial; /** 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 `` 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; }; /** 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; list: (scopeId?: string) => Promise; get: (idOrName: string) => Promise; getResource: (idOrName: string, resourcePath: string) => Promise; sessions: SkillSessionStateLike; requestMutation: (action: SkillMutationAction) => Promise; }; /** * 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 `` 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; }; /** 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[]; };