From cd35b6e0d140eeaca39e0dd683cdd9388c59bcfd Mon Sep 17 00:00:00 2001 From: Malaybhai11 Date: Sat, 13 Jun 2026 16:44:11 +0000 Subject: [PATCH] add TypeScript SDK (@engram/client) mirroring the Python SDK Implements the core EngramClient with ingest, query, get, delete, list, health, and conversation methods. Includes typed errors that match the Python SDK hierarchy so JS/TS agent stacks get the same catch semantics. Closes #22 --- sdk/typescript/.gitignore | 2 + sdk/typescript/README.md | 78 ++++++ sdk/typescript/package-lock.json | 48 ++++ sdk/typescript/package.json | 19 ++ sdk/typescript/src/client.ts | 365 +++++++++++++++++++++++++++++ sdk/typescript/src/errors.ts | 59 +++++ sdk/typescript/src/index.ts | 26 ++ sdk/typescript/src/types.ts | 38 +++ sdk/typescript/test/client.test.js | 86 +++++++ sdk/typescript/tsconfig.json | 18 ++ 10 files changed, 739 insertions(+) create mode 100644 sdk/typescript/.gitignore create mode 100644 sdk/typescript/README.md create mode 100644 sdk/typescript/package-lock.json create mode 100644 sdk/typescript/package.json create mode 100644 sdk/typescript/src/client.ts create mode 100644 sdk/typescript/src/errors.ts create mode 100644 sdk/typescript/src/index.ts create mode 100644 sdk/typescript/src/types.ts create mode 100644 sdk/typescript/test/client.test.js create mode 100644 sdk/typescript/tsconfig.json diff --git a/sdk/typescript/.gitignore b/sdk/typescript/.gitignore new file mode 100644 index 00000000..b9470778 --- /dev/null +++ b/sdk/typescript/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md new file mode 100644 index 00000000..1153c2aa --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,78 @@ +# @engram/client + +TypeScript client for the [Engram](https://github.com/Dipraise1/Engram) decentralized vector database. + +Mirrors the Python `engram.sdk` API so JS/TS agent stacks can use Engram for semantic memory. + +## Install + +```bash +npm install @engram/client +``` + +## Quick Start + +```typescript +import { EngramClient } from "@engram/client"; + +const client = new EngramClient({ + minerUrl: "http://127.0.0.1:8091", +}); + +// Store a memory +const cid = await client.ingest("The transformer architecture changed everything."); +console.log("Stored:", cid); + +// Search +const results = await client.query("attention mechanisms in deep learning", { + topK: 5, +}); +for (const r of results) { + console.log(r.cid, r.score); +} +``` + +## API + +### `new EngramClient(options?)` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `minerUrl` | `string` | `"http://127.0.0.1:8091"` | Miner HTTP endpoint | +| `timeout` | `number` | `30000` | Request timeout (ms) | +| `namespace` | `string` | — | Private namespace name | +| `namespaceKey` | `string` | — | Namespace encryption key | + +### Methods + +- **`ingest(text, metadata?)`** — Store text, returns CID +- **`ingestEmbedding(vector, metadata?)`** — Store a pre-computed embedding +- **`query(text, options?)`** — Semantic search, returns `QueryResult[]` +- **`queryByVector(vector, topK?)`** — Search by vector +- **`get(cid)`** — Retrieve a memory by CID +- **`delete(cid)`** — Delete a memory +- **`list(options?)`** — List memories with optional filter +- **`ingestConversation(messages, options?)`** — Store a conversation +- **`health()`** — Check miner liveness +- **`isOnline()`** — Boolean health check + +### Errors + +All errors extend `EngramError`: + +- `MinerOfflineError` — miner unreachable +- `IngestError` — storage failed +- `QueryError` — search failed +- `InvalidCIDError` — malformed CID from miner + +## Development + +```bash +npm install +npm run build +npm test +``` + +## License + +MIT diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json new file mode 100644 index 00000000..b3f019b4 --- /dev/null +++ b/sdk/typescript/package-lock.json @@ -0,0 +1,48 @@ +{ + "name": "@engram/client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@engram/client", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.4.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 00000000..bbeb076e --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,19 @@ +{ + "name": "@engram/client", + "version": "0.1.0", + "description": "TypeScript client for the Engram decentralized vector database", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "node --test test/*.test.js", + "prepublishOnly": "npm run build" + }, + "keywords": ["engram", "vector", "database", "bittensor", "semantic", "memory"], + "license": "MIT", + "devDependencies": { + "typescript": "^5.4.0", + "@types/node": "^20.0.0" + }, + "files": ["dist", "README.md"] +} diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts new file mode 100644 index 00000000..1feb3605 --- /dev/null +++ b/sdk/typescript/src/client.ts @@ -0,0 +1,365 @@ +/** + * Engram SDK — EngramClient + * + * High-level TypeScript client for the Engram decentralized vector database. + * + * Usage: + * import { EngramClient } from "@engram/client"; + * + * const client = new EngramClient({ minerUrl: "http://127.0.0.1:8091" }); + * + * const cid = await client.ingest("The transformer architecture changed everything."); + * const results = await client.query("attention mechanisms in deep learning", { topK: 5 }); + * for (const r of results) { + * console.log(r.cid, r.score); + * } + */ + +import * as http from "http"; +import * as https from "https"; +import { URL } from "url"; +import { + EngramError, + IngestError, + InvalidCIDError, + MinerOfflineError, + QueryError, +} from "./errors"; +import type { + ClientOptions, + HealthResponse, + MemoryRecord, + Metadata, + QueryResult, +} from "./types"; + +/** CID must start with a known prefix. */ +const CID_PREFIXES = ["v1::", "bafy", "Qm"]; + +function validateCid(cid: string): void { + const valid = CID_PREFIXES.some((p) => cid.startsWith(p)); + if (!valid && cid.length > 4) { + throw new InvalidCIDError(cid); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyObj = Record; + +export class EngramClient { + readonly minerUrl: string; + readonly timeout: number; + readonly namespace: string | undefined; + readonly namespaceKey: string | undefined; + + constructor(options: ClientOptions = {}) { + this.minerUrl = (options.minerUrl || "http://127.0.0.1:8091").replace( + /\/$/, + "" + ); + this.timeout = options.timeout ?? 30_000; + this.namespace = options.namespace; + this.namespaceKey = options.namespaceKey; + } + + // ── Namespace auth ──────────────────────────────────────────────────────── + + private namespaceAuth(): Metadata { + if (!this.namespace) return {}; + return { + namespace: this.namespace, + namespace_key: this.namespaceKey ?? "", + }; + } + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Embed and store text on the miner. + * + * @returns The CID (content identifier) assigned to this embedding. + */ + async ingest(text: string, metadata?: Metadata): Promise { + const payload: AnyObj = { + text, + metadata: metadata ?? {}, + ...this.namespaceAuth(), + }; + + const data: AnyObj = await this.post("IngestSynapse", payload); + + if (data.error) { + throw new IngestError(data.error); + } + + const cid: string | undefined = data.cid; + if (!cid) { + throw new IngestError("Miner returned no CID and no error"); + } + + validateCid(cid); + return cid; + } + + /** + * Store a pre-computed embedding vector on the miner. + */ + async ingestEmbedding( + embedding: number[], + metadata?: Metadata + ): Promise { + const payload: AnyObj = { + raw_embedding: embedding, + metadata: metadata ?? {}, + ...this.namespaceAuth(), + }; + + const data: AnyObj = await this.post("IngestSynapse", payload); + + if (data.error) { + throw new IngestError(data.error); + } + + const cid: string | undefined = data.cid; + if (!cid) { + throw new IngestError("Miner returned no CID and no error"); + } + + validateCid(cid); + return cid; + } + + /** + * Semantic search over the miner's stored embeddings. + */ + async query( + text: string, + options: { topK?: number; filter?: Metadata } = {} + ): Promise { + const { topK = 10, filter } = options; + + const payload: AnyObj = { + query_text: text, + top_k: topK, + ...this.namespaceAuth(), + }; + + if (filter) { + payload.filter = filter; + } + + const data: AnyObj = await this.post("QuerySynapse", payload); + + if (data.error) { + throw new QueryError(data.error); + } + + return (data.results ?? []) as QueryResult[]; + } + + /** + * ANN search using a pre-computed query vector. + */ + async queryByVector(vector: number[], topK = 10): Promise { + const payload: AnyObj = { + query_vector: vector, + top_k: topK, + }; + + const data: AnyObj = await this.post("QuerySynapse", payload); + + if (data.error) { + throw new QueryError(data.error); + } + + return (data.results ?? []) as QueryResult[]; + } + + /** + * Retrieve the metadata for a stored memory by CID. + */ + async get(cid: string): Promise { + const encoded = encodeURIComponent(cid); + const data: AnyObj = await this.httpGet(`retrieve/${encoded}`); + + if (data.error) { + throw new Error(`CID not found: ${cid}`); + } + + return data as MemoryRecord; + } + + /** + * Permanently delete a stored memory by CID. + * + * @returns true if deleted, false if not found. + */ + async delete(cid: string): Promise { + const encoded = encodeURIComponent(cid); + const url = `${this.minerUrl}/retrieve/${encoded}`; + + try { + const data: AnyObj = await this.request(url, { method: "DELETE" }); + return Boolean(data.deleted); + } catch { + return false; + } + } + + /** + * List stored memories, optionally filtered by metadata. + */ + async list( + options: { + filter?: Metadata; + limit?: number; + offset?: number; + } = {} + ): Promise { + const { filter, limit = 50, offset = 0 } = options; + + const payload: AnyObj = { limit, offset }; + if (filter) payload.filter = filter; + if (this.namespace) payload.namespace = this.namespace; + + const data: AnyObj = await this.post("list", payload); + return (data.records ?? []) as MemoryRecord[]; + } + + /** + * Store a conversation (list of messages) as individual memories. + */ + async ingestConversation( + messages: Array<{ role: string; content: string }>, + options: { sessionId?: string; metadata?: Metadata } = {} + ): Promise { + const { sessionId, metadata } = options; + const cids: string[] = []; + + for (const msg of messages) { + const content = msg.content?.trim(); + if (!content) continue; + + const meta: Metadata = { + role: msg.role || "user", + ts: String(Math.floor(Date.now() / 1000)), + text: content.slice(0, 500), + ...(sessionId ? { session: sessionId } : {}), + ...(metadata ?? {}), + }; + + const cid = await this.ingest(content, meta); + cids.push(cid); + } + + return cids; + } + + /** + * Check miner liveness. + */ + async health(): Promise { + const data: AnyObj = await this.httpGet("health"); + return data as HealthResponse; + } + + /** + * Return true if the miner responds to a health check. + */ + async isOnline(): Promise { + try { + await this.health(); + return true; + } catch { + return false; + } + } + + // ── Internal helpers ────────────────────────────────────────────────────── + + private async post( + endpoint: string, + payload: AnyObj + ): Promise { + const url = `${this.minerUrl}/${endpoint}`; + return this.request(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } + + private async httpGet(endpoint: string): Promise { + const url = `${this.minerUrl}/${endpoint}`; + return this.request(url, { method: "GET" }); + } + + private request( + url: string, + options: { + method?: string; + headers?: Record; + body?: string; + } = {} + ): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const isHttps = parsed.protocol === "https:"; + const transport = isHttps ? https : http; + + const reqOptions: http.RequestOptions = { + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname + parsed.search, + method: options.method || "GET", + headers: options.headers ?? {}, + timeout: this.timeout, + }; + + const req = transport.request(reqOptions, (res) => { + let data = ""; + res.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + res.on("end", () => { + try { + const parsed = JSON.parse(data); + resolve(parsed); + } catch { + reject( + new EngramError( + `Failed to parse response from ${url}: ${data.slice(0, 200)}` + ) + ); + } + }); + }); + + req.on("timeout", () => { + req.destroy(); + reject(new MinerOfflineError(url)); + }); + + req.on("error", (err: NodeJS.ErrnoException) => { + if ( + err.code === "ECONNREFUSED" || + err.code === "ENOTFOUND" || + err.code === "ECONNRESET" + ) { + reject(new MinerOfflineError(url, err)); + } else { + reject(new EngramError(`HTTP request failed: ${err.message}`)); + } + }); + + if (options.body) { + req.write(options.body); + } + req.end(); + }); + } + + toString(): string { + return `EngramClient(minerUrl=${JSON.stringify(this.minerUrl)}, timeout=${this.timeout})`; + } +} diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts new file mode 100644 index 00000000..873905d1 --- /dev/null +++ b/sdk/typescript/src/errors.ts @@ -0,0 +1,59 @@ +/** + * Engram SDK — Exception hierarchy. + * + * Mirrors the Python SDK error classes so JS/TS consumers get + * the same catch semantics. + */ + +/** Base class for all Engram SDK errors. */ +export class EngramError extends Error { + constructor(message: string) { + super(message); + this.name = "EngramError"; + } +} + +/** Raised when the miner cannot be reached (connection refused, timeout). */ +export class MinerOfflineError extends EngramError { + url: string; + cause: Error | undefined; + + constructor(url: string, cause?: Error) { + super( + `Can't reach the miner at ${url}. Is it running? Start it with: python neurons/miner.py` + ); + this.name = "MinerOfflineError"; + this.url = url; + this.cause = cause; + } +} + +/** Raised when the miner returns an error on ingest. */ +export class IngestError extends EngramError { + constructor(message: string) { + super(`Couldn't store your data: ${message}`); + this.name = "IngestError"; + } +} + +/** Raised when the miner returns an error on query. */ +export class QueryError extends EngramError { + constructor(message: string) { + super(`Search failed: ${message}`); + this.name = "QueryError"; + } +} + +/** Raised when a CID returned by the miner fails validation. */ +export class InvalidCIDError extends EngramError { + cid: string; + + constructor(cid: string) { + super( + `The miner returned a malformed content ID (${JSON.stringify(cid)}). ` + + "This is a miner-side issue — try a different miner or report it." + ); + this.name = "InvalidCIDError"; + this.cid = cid; + } +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts new file mode 100644 index 00000000..e25b94a6 --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,26 @@ +/** + * @engram/client — TypeScript SDK for the Engram decentralized vector database. + * + * @example + * import { EngramClient } from "@engram/client"; + * + * const client = new EngramClient({ minerUrl: "http://127.0.0.1:8091" }); + * const cid = await client.ingest("Hello from TypeScript!"); + * console.log("Stored:", cid); + */ + +export { EngramClient } from "./client"; +export { + EngramError, + IngestError, + InvalidCIDError, + MinerOfflineError, + QueryError, +} from "./errors"; +export type { + ClientOptions, + HealthResponse, + MemoryRecord, + Metadata, + QueryResult, +} from "./types"; diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts new file mode 100644 index 00000000..615dee70 --- /dev/null +++ b/sdk/typescript/src/types.ts @@ -0,0 +1,38 @@ +/** + * Engram SDK — TypeScript type definitions. + */ + +/** Metadata attached to stored memories. */ +export type Metadata = Record; + +/** A single search result returned by query(). */ +export interface QueryResult { + cid: string; + score: number; + metadata: Metadata; +} + +/** Response from the health endpoint. */ +export interface HealthResponse { + status: string; + vectors: number; + uid: number; +} + +/** Record returned by get() and list(). */ +export interface MemoryRecord { + cid: string; + metadata: Metadata; +} + +/** Options for constructing an EngramClient. */ +export interface ClientOptions { + /** Base URL of the miner HTTP server. @default "http://127.0.0.1:8091" */ + minerUrl?: string; + /** Request timeout in milliseconds. @default 30000 */ + timeout?: number; + /** Private namespace name for encrypted storage. */ + namespace?: string; + /** Secret key for the namespace. */ + namespaceKey?: string; +} diff --git a/sdk/typescript/test/client.test.js b/sdk/typescript/test/client.test.js new file mode 100644 index 00000000..f0881814 --- /dev/null +++ b/sdk/typescript/test/client.test.js @@ -0,0 +1,86 @@ +/** + * Basic tests for @engram/client. + * + * These tests verify the client can be constructed and that error classes + * work correctly. Live integration tests against a real miner are in + * a separate file and require a running node. + */ + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); + +const { + EngramClient, + EngramError, + MinerOfflineError, + IngestError, + QueryError, + InvalidCIDError, +} = require("../dist/index"); + +describe("EngramClient", () => { + it("constructs with defaults", () => { + const client = new EngramClient(); + assert.equal(client.minerUrl, "http://127.0.0.1:8091"); + assert.equal(client.timeout, 30_000); + assert.equal(client.namespace, undefined); + }); + + it("accepts custom options", () => { + const client = new EngramClient({ + minerUrl: "http://remote:9999/", + timeout: 5000, + namespace: "test-ns", + namespaceKey: "secret", + }); + assert.equal(client.minerUrl, "http://remote:9999"); // trailing slash stripped + assert.equal(client.timeout, 5000); + assert.equal(client.namespace, "test-ns"); + assert.equal(client.namespaceKey, "secret"); + }); + + it("toString() returns readable representation", () => { + const client = new EngramClient({ minerUrl: "http://localhost:3000" }); + const str = client.toString(); + assert.ok(str.includes("localhost:3000")); + }); + + it("isOnline returns false when miner is unreachable", async () => { + const client = new EngramClient({ + minerUrl: "http://127.0.0.1:19999", + timeout: 1000, + }); + const online = await client.isOnline(); + assert.equal(online, false); + }); +}); + +describe("Error classes", () => { + it("EngramError is an Error", () => { + const err = new EngramError("test"); + assert.ok(err instanceof Error); + assert.equal(err.name, "EngramError"); + assert.equal(err.message, "test"); + }); + + it("MinerOfflineError includes the URL", () => { + const err = new MinerOfflineError("http://localhost:8080"); + assert.ok(err.message.includes("localhost:8080")); + assert.equal(err.url, "http://localhost:8080"); + }); + + it("IngestError wraps the message", () => { + const err = new IngestError("disk full"); + assert.ok(err.message.includes("disk full")); + }); + + it("QueryError wraps the message", () => { + const err = new QueryError("timeout"); + assert.ok(err.message.includes("timeout")); + }); + + it("InvalidCIDError stores the CID", () => { + const err = new InvalidCIDError("bad-cid"); + assert.equal(err.cid, "bad-cid"); + }); +}); diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json new file mode 100644 index 00000000..9846c819 --- /dev/null +++ b/sdk/typescript/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +}