UNPKG

@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

305 lines (304 loc) • 11.5 kB
/** * S3-backed skill store. * * Layout (curator-compatible): * <prefix>skills/<id>.json — one JSON-serialized SkillDefinition per object * <prefix>index.json — { lastUpdated, skills: SkillIndexItem[] } * * The index document is upserted on every write and rebuilt from a bucket * listing when missing or unparsable (self-healing). Concurrent writers can * race the index read-modify-write; the rebuild path recovers from any * resulting drift, matching curator's production behavior. * * @aws-sdk/client-s3 is an optional peer — loaded lazily with the same * createRequire pattern as memory's @juspay/hippocampus so importing * NeuroLink core never requires the AWS SDK. Tests (and hosts with * pre-configured clients) inject a SkillS3ObjectOps implementation instead. */ import { createRequire } from "node:module"; import { logger } from "../utils/logger.js"; import { isSafeSkillResourcePath, toSkillIndexItem } from "./skillMatcher.js"; const lazyRequire = createRequire(import.meta.url); let cachedS3Module; function loadS3Module() { if (cachedS3Module !== undefined) { return cachedS3Module; } try { cachedS3Module = lazyRequire("@aws-sdk/client-s3"); return cachedS3Module; } catch (error) { cachedS3Module = null; logger.debug("[SkillStoreS3] @aws-sdk/client-s3 is not installed; S3 skill storage unavailable.", { error: error instanceof Error ? error.message : String(error) }); return null; } } /** Build default object ops from @aws-sdk/client-s3. Throws when absent. */ function createDefaultOps(config) { const mod = loadS3Module(); if (!mod) { throw new Error("Skills S3 storage requires @aws-sdk/client-s3. Run `pnpm add @aws-sdk/client-s3` " + "(or your package manager equivalent), or supply a custom store instead."); } const client = new mod.S3Client({ ...(config.region ? { region: config.region } : {}), ...(config.endpoint ? { endpoint: config.endpoint } : {}), ...(config.forcePathStyle !== undefined ? { forcePathStyle: config.forcePathStyle } : {}), ...(config.credentials ? { credentials: config.credentials } : {}), }); const bucket = config.bucket; return { async getObject(key) { try { const response = (await client.send(new mod.GetObjectCommand({ Bucket: bucket, Key: key }))); return (await response.Body?.transformToString()) ?? null; } catch (error) { const name = error.name; if (name === "NoSuchKey" || name === "NotFound") { return null; } throw error; } }, async putObject(key, body) { await client.send(new mod.PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: "application/json", })); }, async deleteObject(key) { await client.send(new mod.DeleteObjectCommand({ Bucket: bucket, Key: key })); }, async listKeys(prefix) { const keys = []; let continuationToken; do { const response = (await client.send(new mod.ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, ...(continuationToken ? { ContinuationToken: continuationToken } : {}), }))); for (const item of response.Contents ?? []) { if (item.Key) { keys.push(item.Key); } } continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; } while (continuationToken); return keys; }, async getObjectConditional(key, etag) { try { const response = (await client.send(new mod.GetObjectCommand({ Bucket: bucket, Key: key, ...(etag ? { IfNoneMatch: etag } : {}), }))); const body = (await response.Body?.transformToString()) ?? null; return { body, ...(response.ETag ? { etag: response.ETag } : {}), }; } catch (error) { const name = error.name; const status = error .$metadata?.httpStatusCode; if (status === 304 || name === "304" || name === "NotModified") { return { body: null, notModified: true }; } if (name === "NoSuchKey" || name === "NotFound") { return { body: null }; } throw error; } }, }; } export class S3SkillStore { config; injectedOps; prefix; ops = null; /** Last parsed index + its ETag, revalidated with If-None-Match reads. */ cachedIndexDoc = null; cachedIndexEtag; constructor(config, /** Test/host seam — omit to build ops from @aws-sdk/client-s3 lazily. */ injectedOps) { this.config = config; this.injectedOps = injectedOps; const rawPrefix = config.prefix ?? "neurolink-skills/"; this.prefix = rawPrefix === "" || rawPrefix.endsWith("/") ? rawPrefix : `${rawPrefix}/`; } invalidate() { this.cachedIndexDoc = null; this.cachedIndexEtag = undefined; } getOps() { if (!this.ops) { this.ops = this.injectedOps ?? createDefaultOps(this.config); } return this.ops; } skillKey(id) { return `${this.prefix}skills/${id}.json`; } indexKey() { return `${this.prefix}index.json`; } /** Resources live beside the skill object: <prefix>skills/<id>/<path>. */ resourceKey(id, resourcePath) { return `${this.prefix}skills/${id}/${resourcePath}`; } async getResource(id, resourcePath) { if (!isSafeSkillResourcePath(resourcePath)) { return null; } return this.getOps().getObject(this.resourceKey(id, resourcePath)); } async get(id) { const body = await this.getOps().getObject(this.skillKey(id)); if (!body) { return null; } try { return JSON.parse(body); } catch (error) { logger.warn("[SkillStoreS3] Failed to parse skill object", { id, error: error instanceof Error ? error.message : String(error), }); return null; } } async put(skill) { const ops = this.getOps(); await ops.putObject(this.skillKey(skill.id), JSON.stringify(skill, null, 2)); const index = await this.readOrRebuildIndex(); const entry = toSkillIndexItem(skill); const position = index.skills.findIndex((s) => s.id === skill.id); if (position >= 0) { index.skills[position] = entry; } else { index.skills.push(entry); } await this.writeIndex(index); } async delete(id) { const ops = this.getOps(); await ops.deleteObject(this.skillKey(id)); const index = await this.readOrRebuildIndex(); index.skills = index.skills.filter((s) => s.id !== id); await this.writeIndex(index); } async index() { const index = await this.readOrRebuildIndex(); return index.skills; } async writeIndex(index) { index.lastUpdated = new Date().toISOString(); await this.getOps().putObject(this.indexKey(), JSON.stringify(index, null, 2)); // The write changed the object's ETag; keep the doc, revalidate next read. this.cachedIndexDoc = index; this.cachedIndexEtag = undefined; } /** * Raw index.json read. Returns the body (with its ETag when available), * null body when the object is absent, or notModified when a * conditional read reported the cached copy unchanged. */ async readIndexObject(ops) { if (ops.getObjectConditional) { return ops.getObjectConditional(this.indexKey(), this.cachedIndexDoc ? this.cachedIndexEtag : undefined); } return { body: await ops.getObject(this.indexKey()) }; } /** * Read index.json (ETag-revalidated when the ops support conditional * reads — an unchanged index costs a 304, not a download); when missing * or unparsable, rebuild it from a listing of the skills/ prefix and * persist the rebuilt document (self-heal). */ async readOrRebuildIndex() { const ops = this.getOps(); const read = await this.readIndexObject(ops); if (read.notModified && this.cachedIndexDoc) { return this.cachedIndexDoc; } if (read.body) { try { const parsed = JSON.parse(read.body); if (Array.isArray(parsed.skills)) { this.cachedIndexDoc = parsed; // Cache the ETag only for a body that parsed: pairing a corrupt // object's ETag with the previous good doc would make every // later conditional read 304 into permanently stale data. this.cachedIndexEtag = read.etag; return parsed; } } catch (error) { logger.warn("[SkillStoreS3] index.json unparsable — rebuilding", { error: error instanceof Error ? error.message : String(error), }); } this.cachedIndexEtag = undefined; } const skillsPrefix = `${this.prefix}skills/`; const keys = await ops.listKeys(skillsPrefix); const skills = []; for (const key of keys) { if (!key.endsWith(".json")) { continue; } const body = await ops.getObject(key); if (!body) { continue; } try { skills.push(toSkillIndexItem(JSON.parse(body))); } catch { logger.warn("[SkillStoreS3] Skipping unparsable skill during rebuild", { key, }); } } const rebuilt = { lastUpdated: new Date().toISOString(), skills, }; // Cache the rebuilt doc even when persistence below fails, so reads // don't fall back to a stale pre-rebuild copy. this.cachedIndexDoc = rebuilt; this.cachedIndexEtag = undefined; try { await this.writeIndex(rebuilt); logger.info("[SkillStoreS3] Rebuilt skills index", { count: skills.length, }); } catch (error) { // Index persistence is best-effort; the rebuilt in-memory copy is // still returned so reads keep working. logger.warn("[SkillStoreS3] Failed to persist rebuilt index", { error: error instanceof Error ? error.message : String(error), }); } return rebuilt; } }