diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 00000000..cb067626 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,27 @@ + + + + + + Engram Miner API Documentation + + + + + + + + + \ No newline at end of file diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 00000000..801a4db4 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,846 @@ +openapi: 3.0.3 +info: + title: Engram Miner API + description: | + OpenAPI specification for Engram miner HTTP endpoints. + + ## Authentication + Most endpoints require sr25519 signed challenge headers: + - `X-Signature`: sr25519 signature of the request + - `X-Timestamp`: Unix timestamp + - `X-Nonce`: Random nonce + + ## Rate Limiting + API calls are rate-limited per IP address. + version: 1.0.0 + contact: + name: Engram Team + url: https://github.com/Dipraise1/Engram + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: http://localhost:8090 + description: Local development server + - url: https://miner.engram.space + description: Production server + +tags: + - name: Core + description: Core data operations (ingest, query, retrieve) + - name: Chat + description: Chat history and conversation management + - name: KeyShare + description: Key share storage and retrieval + - name: Namespace + description: Namespace management and attestation + - name: System + description: Health checks, stats, and monitoring + +paths: + /health: + get: + tags: [System] + summary: Health check + description: Liveness probe for the miner service + operationId: getHealth + responses: + '200': + description: Service is healthy + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + timestamp: + type: integer + format: int64 + version: + type: string + example: "1.0.0" + + /IngestSynapse: + post: + tags: [Core] + summary: Ingest data + description: Store embedding and return CID + operationId: ingestSynapse + security: + - sr25519Auth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IngestRequest' + responses: + '200': + description: Data ingested successfully + content: + application/json: + schema: + $ref: '#/components/schemas/IngestResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + + /QuerySynapse: + post: + tags: [Core] + summary: Query data + description: ANN search, return top-K results + operationId: querySynapse + security: + - sr25519Auth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QueryRequest' + responses: + '200': + description: Query results + content: + application/json: + schema: + $ref: '#/components/schemas/QueryResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/RateLimited' + + /ChallengeSynapse: + post: + tags: [Core] + summary: Storage proof challenge + description: Storage proof response using validator's nonce + operationId: challengeSynapse + security: + - sr25519Auth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChallengeRequest' + responses: + '200': + description: Challenge response + content: + application/json: + schema: + $ref: '#/components/schemas/ChallengeResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /retrieve/{cid}: + get: + tags: [Core] + summary: Retrieve data + description: Retrieve data by CID + operationId: retrieveData + parameters: + - name: cid + in: path + required: true + schema: + type: string + description: Content identifier + responses: + '200': + description: Retrieved data + content: + application/json: + schema: + type: object + properties: + cid: + type: string + data: + type: object + timestamp: + type: integer + format: int64 + '404': + description: Data not found + delete: + tags: [Core] + summary: Delete data + description: Delete data by CID + operationId: deleteData + security: + - sr25519Auth: [] + parameters: + - name: cid + in: path + required: true + schema: + type: string + description: Content identifier + responses: + '200': + description: Data deleted + '404': + description: Data not found + + /RepairSynapse: + post: + tags: [Core] + summary: Repair retrieve + description: Repair and retrieve data + operationId: repairSynapse + security: + - sr25519Auth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + cid: + type: string + repair_type: + type: string + enum: [full, partial, verify] + responses: + '200': + description: Repair completed + '404': + description: Data not found + + /list: + post: + tags: [Core] + summary: List data + description: List stored data with filters + operationId: listData + security: + - sr25519Auth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + limit: + type: integer + default: 100 + offset: + type: integer + default: 0 + namespace: + type: string + responses: + '200': + description: List of data + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + total: + type: integer + + /conversations: + get: + tags: [Chat] + summary: List conversations + description: Get conversations for a user + operationId: listConversations + parameters: + - name: user_id + in: query + required: true + schema: + type: string + responses: + '200': + description: List of conversations + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Conversation' + post: + tags: [Chat] + summary: Create conversation + description: Create a new conversation + operationId: createConversation + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateConversationRequest' + responses: + '201': + description: Conversation created + content: + application/json: + schema: + $ref: '#/components/schemas/Conversation' + + /conversations/{conv_id}: + patch: + tags: [Chat] + summary: Update conversation + description: Update conversation metadata + operationId: updateConversation + parameters: + - name: conv_id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + title: + type: string + metadata: + type: object + responses: + '200': + description: Conversation updated + delete: + tags: [Chat] + summary: Delete conversation + description: Delete a conversation + operationId: deleteConversation + parameters: + - name: conv_id + in: path + required: true + schema: + type: string + responses: + '200': + description: Conversation deleted + + /chat-history: + post: + tags: [Chat] + summary: Add chat history + description: Add a message to chat history + operationId: addChatHistory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatMessage' + responses: + '201': + description: Message added + + /chat-history/{user_id}: + get: + tags: [Chat] + summary: Get chat history + description: Get chat history for a user + operationId: getChatHistory + parameters: + - name: user_id + in: path + required: true + schema: + type: string + - name: limit + in: query + schema: + type: integer + default: 50 + - name: offset + in: query + schema: + type: integer + default: 0 + responses: + '200': + description: Chat history + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ChatMessage' + + /namespace: + post: + tags: [Namespace] + summary: Create namespace + description: Create a new namespace + operationId: createNamespace + security: + - sr25519Auth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + description: + type: string + public: + type: boolean + default: false + responses: + '201': + description: Namespace created + + /AttestNamespace: + post: + tags: [Namespace] + summary: Attest namespace + description: Create attestation for a namespace + operationId: attestNamespace + security: + - sr25519Auth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - namespace + properties: + namespace: + type: string + attestation_data: + type: object + responses: + '200': + description: Attestation created + + /attestation/{namespace}: + get: + tags: [Namespace] + summary: Get attestation + description: Get attestation for a namespace + operationId: getAttestation + parameters: + - name: namespace + in: path + required: true + schema: + type: string + responses: + '200': + description: Attestation data + content: + application/json: + schema: + type: object + properties: + namespace: + type: string + attestation: + type: object + timestamp: + type: integer + format: int64 + + /KeyShareSynapse: + post: + tags: [KeyShare] + summary: Store key share + description: Store a key share + operationId: storeKeyShare + security: + - sr25519Auth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/KeyShareRequest' + responses: + '200': + description: Key share stored + + /KeyShareRetrieve: + post: + tags: [KeyShare] + summary: Retrieve key share + description: Retrieve a key share + operationId: retrieveKeyShare + security: + - sr25519Auth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - key_id + properties: + key_id: + type: string + responses: + '200': + description: Key share data + content: + application/json: + schema: + type: object + properties: + key_id: + type: string + share: + type: string + metadata: + type: object + + /stats: + get: + tags: [System] + summary: Get stats + description: Get miner statistics + operationId: getStats + responses: + '200': + description: Miner statistics + content: + application/json: + schema: + type: object + properties: + total_ingested: + type: integer + total_queries: + type: integer + uptime_seconds: + type: integer + storage_used_bytes: + type: integer + + /metagraph: + get: + tags: [System] + summary: Get metagraph + description: Get network metagraph information + operationId: getMetagraph + responses: + '200': + description: Metagraph data + content: + application/json: + schema: + type: object + properties: + neurons: + type: array + items: + type: object + network: + type: string + block: + type: integer + + /metrics: + get: + tags: [System] + summary: Get metrics + description: Get Prometheus-compatible metrics (localhost only) + operationId: getMetrics + responses: + '200': + description: Prometheus metrics + content: + text/plain: + schema: + type: string + + /wallet-stats: + get: + tags: [System] + summary: Get wallet stats + description: Get wallet statistics + operationId: getWalletStats + responses: + '200': + description: Wallet statistics + + /wallet-stats/{hotkey}: + get: + tags: [System] + summary: Get wallet stats by hotkey + description: Get statistics for a specific wallet + operationId: getWalletStatsByHotkey + parameters: + - name: hotkey + in: path + required: true + schema: + type: string + responses: + '200': + description: Wallet statistics + + /commitment: + get: + tags: [System] + summary: Get commitment + description: Get miner commitment information + operationId: getCommitment + responses: + '200': + description: Commitment data + + /prove-memory: + post: + tags: [System] + summary: Prove memory + description: Generate memory proof + operationId: proveMemory + security: + - sr25519Auth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + memory_size: + type: integer + proof_type: + type: string + enum: [basic, advanced, zk] + responses: + '200': + description: Memory proof generated + +components: + securitySchemes: + sr25519Auth: + type: apiKey + in: header + name: X-Signature + description: | + sr25519 signed challenge header. + + Include these headers: + - `X-Signature`: sr25519 signature + - `X-Timestamp`: Unix timestamp + - `X-Nonce`: Random nonce + + schemas: + IngestRequest: + type: object + required: + - data + properties: + data: + type: string + description: Data to ingest (base64 encoded) + namespace: + type: string + description: Optional namespace + metadata: + type: object + description: Optional metadata + + IngestResponse: + type: object + properties: + cid: + type: string + description: Content identifier + timestamp: + type: integer + format: int64 + size_bytes: + type: integer + + QueryRequest: + type: object + required: + - query + properties: + query: + type: string + description: Query string + top_k: + type: integer + default: 10 + description: Number of results to return + namespace: + type: string + description: Optional namespace filter + + QueryResponse: + type: object + properties: + results: + type: array + items: + type: object + properties: + cid: + type: string + score: + type: number + format: float + data: + type: object + total: + type: integer + + ChallengeRequest: + type: object + required: + - nonce + properties: + nonce: + type: string + description: Validator nonce (hex) + cid: + type: string + description: Content identifier to prove + validator_hotkey: + type: string + description: Validator hotkey (hex) + + ChallengeResponse: + type: object + properties: + embedding_hash: + type: string + proof: + type: string + timestamp: + type: integer + format: int64 + + Conversation: + type: object + properties: + id: + type: string + user_id: + type: string + title: + type: string + created_at: + type: integer + format: int64 + updated_at: + type: integer + format: int64 + metadata: + type: object + + CreateConversationRequest: + type: object + required: + - user_id + properties: + user_id: + type: string + title: + type: string + metadata: + type: object + + ChatMessage: + type: object + required: + - user_id + - content + properties: + user_id: + type: string + conversation_id: + type: string + role: + type: string + enum: [user, assistant, system] + content: + type: string + timestamp: + type: integer + format: int64 + + KeyShareRequest: + type: object + required: + - key_id + - share + properties: + key_id: + type: string + share: + type: string + metadata: + type: object + + Error: + type: object + properties: + error: + type: string + message: + type: string + code: + type: integer + + responses: + Unauthorized: + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: "Unauthorized" + message: "Invalid signature" + code: 401 + + RateLimited: + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: "Too Many Requests" + message: "Rate limit exceeded" + code: 429 + + InternalError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + error: "Internal Server Error" + message: "An unexpected error occurred" + code: 500 + +security: + - sr25519Auth: [] diff --git a/sdk/typescript/.github/workflows/ci.yml b/sdk/typescript/.github/workflows/ci.yml new file mode 100644 index 00000000..c33fb617 --- /dev/null +++ b/sdk/typescript/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/sdk/typescript/.gitignore b/sdk/typescript/.gitignore new file mode 100644 index 00000000..f4e2c6d6 --- /dev/null +++ b/sdk/typescript/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md new file mode 100644 index 00000000..c12e480d --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,135 @@ +--- +AIGC: + Label: "1" + ContentProducer: 001191440300708461136T1XGW3 + ProduceID: acb9c50358234750b38baff6e88cca8d_4977ea286ae111f18805525400d9a7a1 + ReservedCode1: 5PtZXgpyNUqM6Bm/IEWxQASD4dT4L4V6xhfs4OB6FrFb5SfQi+4Z5TOAowk7MEdp0FMplThuY13wXmEWeit0C7DueQTx4KnVe5BPWyem/dJ4OM9POvOshq/yGyVx6f75y4j+ONcus3WbfCYQwUnbpex+Fe5CtYqC7Hxm+zEXslr9XIpcBBLX7Un+CoA= + ContentPropagator: 001191440300708461136T1XGW3 + PropagateID: acb9c50358234750b38baff6e88cca8d_4977ea286ae111f18805525400d9a7a1 + ReservedCode2: 5PtZXgpyNUqM6Bm/IEWxQASD4dT4L4V6xhfs4OB6FrFb5SfQi+4Z5TOAowk7MEdp0FMplThuY13wXmEWeit0C7DueQTx4KnVe5BPWyem/dJ4OM9POvOshq/yGyVx6f75y4j+ONcus3WbfCYQwUnbpex+Fe5CtYqC7Hxm+zEXslr9XIpcBBLX7Un+CoA= +--- + +# @engram/client + +TypeScript SDK for the [Engram](https://engram.org) decentralized knowledge graph. + +Mirrors the Python SDK (`engram/sdk/client.py`) with full TypeScript type safety. + +## Installation + +```bash +npm install @engram/client +``` + +For sr25519 namespace signing support (optional): + +```bash +npm install @polkadot/util-crypto +``` + +## Quick Start + +```typescript +import { EngramClient } from '@engram/client'; + +const client = new EngramClient({ + miner_url: 'http://127.0.0.1:8091', + timeout: 30000, +}); + +// Ingest text +const cid = await client.ingest('Hello, Engram!', { source: 'tutorial' }); +console.log('Ingested:', cid); + +// Query +const results = await client.query('Hello', 5); +for (const r of results) { + console.log(`${r.cid}: score=${r.score}`); +} + +// Health check +const health = await client.health(); +console.log('Miner status:', health.status); + +// Check if online +const online = await client.isOnline(); +``` + +## Namespace Authentication + +```typescript +import { EngramClient } from '@engram/client'; +import { Keyring } from '@polkadot/keyring'; + +const keyring = new Keyring({ type: 'sr25519' }); +const pair = keyring.addFromUri('//Alice'); + +const client = new EngramClient({ + namespace: 'my-namespace', + keypair: pair, +}); + +// Requests will include sr25519-signed namespace auth headers +await client.ingest('secured data'); +``` + +## API Reference + +### Constructor + +```typescript +new EngramClient(options?: EngramClientOptions) +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `miner_url` | `string` | `http://127.0.0.1:8091` | Miner endpoint URL | +| `timeout` | `number` | `30000` | Request timeout in ms | +| `namespace` | `string` | — | Namespace for data isolation | +| `namespace_key` | `string` | — | Plain namespace key | +| `keypair` | `KeyringPair` | — | sr25519 keypair for signed auth | + +### Core Methods + +- **`ingest(text, metadata?)`** → `Promise` — Ingest text, returns CID +- **`ingestEmbedding(embedding, metadata?)`** → `Promise` — Ingest a pre-computed vector +- **`query(text, topK?, filter?)`** → `Promise` — Semantic search +- **`queryByVector(vector, topK?)`** → `Promise` — Vector search +- **`get(cid)`** → `Promise` — Retrieve by CID +- **`delete(cid)`** → `Promise` — Delete by CID +- **`list(filter?, limit?, offset?)`** → `Promise` — List records +- **`health()`** → `Promise` — Health check +- **`isOnline()`** → `Promise` — Check miner availability + +### Content Ingestion + +- **`ingestImage(source, xaiApiKey, model?)`** → `Promise` — Describe via xAI vision, then ingest +- **`ingestPdf(source)`** → `Promise` — Extract and ingest PDF text +- **`ingestUrl(url)`** → `Promise` — Fetch and ingest web page content +- **`ingestConversation(messages, sessionId?)`** → `Promise` — Ingest conversation messages +- **`batchIngestFile(path, options?)`** → `Promise` — Batch ingest from JSONL file + +### Error Classes + +| Class | Description | +|-------|-------------| +| `EngramError` | Base error class | +| `MinerOfflineError` | Miner unreachable | +| `IngestError` | Ingestion failure | +| `QueryError` | Query failure | +| `InvalidCIDError` | CID not found or malformed | + +## Development + +```bash +git clone https://github.com/engramhq/engram-client.git +cd engram-client +npm install +npm test # Run tests (vitest) +npm run build # Compile TypeScript +``` + +## License + +MIT +*(内容由AI生成,仅供参考)* diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 00000000..716c36d0 --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,46 @@ +{ + "name": "@engram/client", + "version": "0.1.0", + "description": "TypeScript SDK for Engram decentralized knowledge graph", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "engram", + "knowledge-graph", + "vector-database", + "decentralized" + ], + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0", + "@types/pdf-parse": "^1.1.4", + "typescript": "^5.4.0", + "vitest": "^1.6.0" + }, + "dependencies": { + "pdf-parse": "^1.1.1" + }, + "optionalDependencies": { + "@polkadot/util-crypto": "^13.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts new file mode 100644 index 00000000..9ebb6c1a --- /dev/null +++ b/sdk/typescript/src/client.ts @@ -0,0 +1,506 @@ +/** + * EngramClient — TypeScript SDK for Engram decentralized knowledge graph. + * + * Mirrors the Python SDK (engram/sdk/client.py). + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { namespaceAuth } from './crypto.js'; +import { + EngramError, + MinerOfflineError, + IngestError, + QueryError, + InvalidCIDError, +} from './errors.js'; +import type { + ApiResponse, + BatchIngestOptions, + ConversationMessage, + EngramClientOptions, + Filter, + HealthResponse, + ImageIngestResult, + IngestOptions, + Metadata, + PdfIngestResult, + QueryResult, + EngramRecord, + UrlIngestResult, +} from './types.js'; + +// Re-export for consumers +export { EngramError, MinerOfflineError, IngestError, QueryError, InvalidCIDError } from './errors.js'; +export type * from './types.js'; +export { namespaceAuth, isSr25519Available } from './crypto.js'; + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +const DEFAULT_MINER_URL = 'http://127.0.0.1:8091'; +const DEFAULT_TIMEOUT = 30_000; // ms + +// --------------------------------------------------------------------------- +// EngramClient +// --------------------------------------------------------------------------- + +export class EngramClient { + public readonly minerUrl: string; + public readonly timeout: number; + public readonly namespace: string | undefined; + private readonly namespaceKey: string | undefined; + private readonly keypair: unknown | undefined; + private _httpAgent: (url: string, options: RequestInit) => Promise; + + constructor(options: EngramClientOptions = {}) { + this.minerUrl = (options.miner_url ?? DEFAULT_MINER_URL).replace(/\/+$/, ''); + this.timeout = options.timeout ?? DEFAULT_TIMEOUT; + this.namespace = options.namespace; + this.namespaceKey = options.namespace_key; + this.keypair = options.keypair; + + // Allow injecting a custom fetch for testing + this._httpAgent = async (url: string, init: RequestInit): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeout); + try { + const resp = await fetch(url, { ...init, signal: controller.signal }); + return resp; + } finally { + clearTimeout(timer); + } + }; + } + + /** + * Override the HTTP agent (primarily for tests). + */ + _setHttpAgent(agent: (url: string, options: RequestInit) => Promise): void { + this._httpAgent = agent; + } + + // ----------------------------------------------------------------------- + // Network layer + // ----------------------------------------------------------------------- + + private async _post(endpoint: string, payload: Metadata): Promise { + const url = `${this.minerUrl}/${endpoint}`; + let body = JSON.stringify(payload); + let headers: Record = { 'Content-Type': 'application/json' }; + + // Inject namespace auth + const auth = await namespaceAuth(this.namespace, this.namespaceKey, this.keypair); + if (Object.keys(auth).length > 0) { + const merged = { ...payload, ...auth }; + body = JSON.stringify(merged); + } + + let resp: Response; + try { + resp = await this._httpAgent(url, { + method: 'POST', + headers, + body, + }); + } catch (err) { + throw new MinerOfflineError(`POST ${endpoint}: ${String(err)}`); + } + + if (!resp.ok) { + throw new MinerOfflineError(`HTTP ${resp.status} on POST ${endpoint}`); + } + + const data = await resp.json() as ApiResponse; + return data; + } + + private async _get(endpoint: string): Promise { + const url = `${this.minerUrl}/${endpoint}`; + let resp: Response; + try { + resp = await this._httpAgent(url, { method: 'GET' }); + } catch (err) { + throw new MinerOfflineError(`GET ${endpoint}: ${String(err)}`); + } + + if (!resp.ok) { + throw new MinerOfflineError(`HTTP ${resp.status} on GET ${endpoint}`); + } + + const data = await resp.json() as ApiResponse; + return data; + } + + // ----------------------------------------------------------------------- + // Core API methods + // ----------------------------------------------------------------------- + + /** + * Ingest text into the knowledge graph. + * @returns The CID string of the ingested record. + */ + async ingest(text: string, metadata?: Metadata): Promise { + const payload: Metadata = { text, metadata: metadata ?? {} }; + const data = await this._post('IngestSynapse', payload); + if (data.error) throw new IngestError(String(data.error)); + const cid = data.cid; + if (!cid || typeof cid !== 'string') { + throw new IngestError('Miner returned no CID'); + } + return cid; + } + + /** + * Ingest a pre-computed embedding vector. + * @returns The CID string. + */ + async ingestEmbedding(embedding: number[], metadata?: Metadata): Promise { + const payload: Metadata = { embedding, metadata: metadata ?? {} }; + const data = await this._post('IngestEmbedding', payload); + if (data.error) throw new IngestError(String(data.error)); + const cid = data.cid; + if (!cid || typeof cid !== 'string') { + throw new IngestError('Miner returned no CID'); + } + return cid; + } + + /** + * Query the knowledge graph by natural language text. + * @returns Array of query results with cid, score, and metadata. + */ + async query( + text: string, + topK: number = 10, + filter?: Filter, + ): Promise { + const payload: Metadata = { query_text: text, top_k: topK }; + if (filter) payload.filter = filter; + const data = await this._post('QuerySynapse', payload); + if (data.error) throw new QueryError(String(data.error)); + return (data.results as QueryResult[]) ?? []; + } + + /** + * Query by embedding vector. + * @returns Array of query results. + */ + async queryByVector(vector: number[], topK: number = 10): Promise { + const payload: Metadata = { vector, top_k: topK }; + const data = await this._post('QueryByVector', payload); + if (data.error) throw new QueryError(String(data.error)); + return (data.results as QueryResult[]) ?? []; + } + + /** + * Retrieve a single record by CID. + */ + async get(cid: string): Promise { + const payload: Metadata = { cid, metadata: {} }; + const data = await this._post('GetSynapse', payload); + if (data.error) throw new InvalidCIDError(String(data.error)); + return { + cid: (data.cid as string) ?? cid, + metadata: (data.metadata as Metadata) ?? {}, + }; + } + + /** + * Delete a record by CID. + * @returns true if successfully deleted. + */ + async delete(cid: string): Promise { + const payload: Metadata = { cid }; + const data = await this._post('DeleteSynapse', payload); + if (data.error) throw new InvalidCIDError(String(data.error)); + return true; + } + + /** + * List records with optional filter, limit, and offset. + */ + async list( + filter?: Filter, + limit: number = 100, + offset: number = 0, + ): Promise { + const payload: Metadata = { limit, offset }; + if (filter) payload.filter = filter; + const data = await this._post('ListSynapses', payload); + if (data.error) throw new QueryError(String(data.error)); + return (data.records as EngramRecord[]) ?? []; + } + + /** + * Health check. + */ + async health(): Promise { + const data = await this._get('health'); + return { + status: (data.status as string) ?? 'unknown', + vectors: (data.vectors as number) ?? 0, + uid: (data.uid as string) ?? '', + }; + } + + /** + * Check if the miner is online. + */ + async isOnline(): Promise { + try { + await this.health(); + return true; + } catch { + return false; + } + } + + // ----------------------------------------------------------------------- + // Batch ingest + // ----------------------------------------------------------------------- + + /** + * Batch-ingest all records from a JSON-lines file. + * + * Each line must be a JSON object with `text` (required) and optional `metadata`. + */ + async batchIngestFile( + filePath: string, + options: BatchIngestOptions = {}, + ): Promise { + const returnErrors = options.return_errors ?? false; + + const absPath = path.resolve(filePath); + const content = fs.readFileSync(absPath, 'utf-8'); + const lines = content.split(/\r?\n/).filter((l) => l.trim()); + + const cids: string[] = []; + const errors: string[] = []; + + for (const line of lines) { + try { + const obj = JSON.parse(line); + const text = obj.text; + if (!text) { + throw new IngestError('Missing "text" field in batch line'); + } + const cid = await this.ingest(text, obj.metadata); + cids.push(cid); + } catch (err) { + if (returnErrors) { + errors.push(String(err)); + } else { + throw err; + } + } + } + + return returnErrors ? [cids, errors] : cids; + } + + // ----------------------------------------------------------------------- + // Ingest image (via xAI API) + // ----------------------------------------------------------------------- + + /** + * Ingest an image by sending it to xAI for description, then ingesting + * both the description and the base64-encoded content. + */ + async ingestImage( + source: string, + xaiApiKey: string, + model: string = 'grok-2-vision-latest', + ): Promise { + const imageData = fs.readFileSync(source); + const base64 = imageData.toString('base64'); + const filename = path.basename(source); + const ext = path.extname(filename).toLowerCase().replace('.', ''); + const mimeType = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg'; + + // Get description from xAI + let description: string; + try { + const xaiResp = await fetch('https://api.x.ai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${xaiApiKey}`, + }, + body: JSON.stringify({ + model, + messages: [ + { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { url: `data:${mimeType};base64,${base64}`, detail: 'high' }, + }, + { + type: 'text', + text: 'Describe this image in detail, capturing all visible elements, text, colors, and context.', + }, + ], + }, + ], + max_tokens: 500, + }), + }); + + if (!xaiResp.ok) { + throw new EngramError(`xAI API returned HTTP ${xaiResp.status}`); + } + + const xaiData = await xaiResp.json() as { + choices?: Array<{ message?: { content?: string } }>; + }; + description = xaiData.choices?.[0]?.message?.content ?? ''; + } catch (err) { + throw new EngramError(`xAI vision failed: ${String(err)}`); + } + + // Ingest description + const descCid = await this.ingest(description, { filename, type: 'image_description' }); + + // Ingest image content (base64 encoded) + const contentCid = await this.ingest(base64, { + filename, + type: 'image_content', + encoding: 'base64', + mime_type: mimeType, + }); + + return { cid: descCid, description, content_cid: contentCid, filename }; + } + + // ----------------------------------------------------------------------- + // Ingest PDF + // ----------------------------------------------------------------------- + + /** + * Ingest a PDF file: extract text with pdf-parse, then ingest. + */ + async ingestPdf(source: string): Promise { + const filename = path.basename(source); + const pdfData = fs.readFileSync(source); + + let pdfParse: (buf: Buffer) => Promise<{ text: string; numpages: number }>; + try { + const mod = await import('pdf-parse'); + pdfParse = mod.default as typeof pdfParse; + } catch { + throw new EngramError('pdf-parse is not installed. Run: npm install pdf-parse'); + } + + const parsed = await pdfParse(pdfData); + const text = parsed.text; + const pages = parsed.numpages; + const chars = text.length; + + // Ingest full text + const contentCid = await this.ingest(text, { + filename, + type: 'pdf_content', + pages, + chars, + }); + + // Ingest metadata summary + const summaryCid = await this.ingest( + `PDF file "${filename}" with ${pages} page(s), ${chars} characters.`, + { filename, type: 'pdf_summary', pages, chars, content_cid: contentCid }, + ); + + return { + cid: summaryCid, + pages, + chars, + content_cid: contentCid, + filename, + }; + } + + // ----------------------------------------------------------------------- + // Ingest URL + // ----------------------------------------------------------------------- + + /** + * Ingest a URL: fetch and extract text, then ingest. + */ + async ingestUrl(url: string): Promise { + let html: string; + try { + const resp = await fetch(url, { + headers: { 'User-Agent': 'EngramSDK/0.1' }, + }); + if (!resp.ok) { + throw new EngramError(`HTTP ${resp.status} fetching ${url}`); + } + html = await resp.text(); + } catch (err) { + throw new EngramError(`Failed to fetch URL: ${String(err)}`); + } + + // Extract title + const titleMatch = html.match(/]*>([^<]+)<\/title>/i); + const title = titleMatch ? titleMatch[1].trim() : url; + + // Strip HTML tags for plain text + const text = html + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); + + const chars = text.length; + const cid = await this.ingest(text, { url, title, type: 'url_content', chars }); + + return { cid, url, title, chars }; + } + + // ----------------------------------------------------------------------- + // Ingest conversation + // ----------------------------------------------------------------------- + + /** + * Ingest a conversation (array of messages). + * Each message is ingested individually + a summary record. + */ + async ingestConversation( + messages: ConversationMessage[], + sessionId?: string, + ): Promise { + const cids: string[] = []; + const sid = sessionId ?? `conv_${Date.now()}`; + + for (const msg of messages) { + const cid = await this.ingest(msg.content, { + role: msg.role, + session_id: sid, + type: 'conversation_message', + }); + cids.push(cid); + } + + // Ingest conversation summary + const summary = `Conversation (${sid}) with ${messages.length} messages. ` + + `Roles: ${messages.map((m) => m.role).join(', ')}`; + const summaryCid = await this.ingest(summary, { + session_id: sid, + type: 'conversation_summary', + message_count: messages.length, + }); + cids.push(summaryCid); + + return cids; + } +} diff --git a/sdk/typescript/src/crypto.ts b/sdk/typescript/src/crypto.ts new file mode 100644 index 00000000..260a7df1 --- /dev/null +++ b/sdk/typescript/src/crypto.ts @@ -0,0 +1,177 @@ +/** + * Cryptographic utilities for Engram SDK. + * + * - sr25519 signing for namespace authentication (optional, via @polkadot/util-crypto) + * - X25519 key agreement for namespace encryption (Node.js crypto) + */ + +import type { Metadata } from './types.js'; + +// --------------------------------------------------------------------------- +// sr25519 helpers (optional dependency — fails gracefully if not installed) +// --------------------------------------------------------------------------- + +let _polkadotCrypto: { + cryptoWaitReady: () => Promise; + sr25519Sign: (message: Uint8Array, keypair: { publicKey: Uint8Array; secretKey: Uint8Array }) => Uint8Array; + encodeAddress: (publicKey: Uint8Array, ss58Format?: number) => string; +} | null = null; + +let _polkadotLoaded = false; +let _polkadotLoadAttempted = false; + +async function ensurePolkadot(): Promise { + if (_polkadotLoaded) return true; + if (_polkadotLoadAttempted) return false; + _polkadotLoadAttempted = true; + + try { + const pkg = await import('@polkadot/util-crypto'); + _polkadotCrypto = { + cryptoWaitReady: pkg.cryptoWaitReady, + sr25519Sign: pkg.sr25519Sign, + encodeAddress: pkg.encodeAddress, + }; + await pkg.cryptoWaitReady(); + _polkadotLoaded = true; + return true; + } catch { + return false; + } +} + +/** Check whether sr25519 support is available. */ +export async function isSr25519Available(): Promise { + return ensurePolkadot(); +} + +/** + * Sign a namespace-auth message using the provided sr25519 keypair. + * Returns an auth header object, or an empty object if namespace is not set. + */ +export async function namespaceAuth( + namespace: string | undefined, + namespaceKey: string | undefined, + keypair: unknown | undefined, +): Promise { + if (!namespace) return {}; + + // If keypair is provided, do sr25519 signing + if (keypair) { + const available = await ensurePolkadot(); + if (!available) { + throw new Error( + 'sr25519 keypair provided but @polkadot/util-crypto is not installed. ' + + 'Install with: npm install @polkadot/util-crypto', + ); + } + + const ts = Date.now(); + const msg = new TextEncoder().encode(`engram-ns:${namespace}:${ts}`); + const kp = keypair as { sign: (msg: Uint8Array) => Uint8Array; publicKey: Uint8Array }; + + // Use polkadot sr25519Sign + let sig: Uint8Array; + let address: string; + try { + // For KeyringPair, use its own sign method + sig = kp.sign(msg); + // Use polkadot encodeAddress for ss58 + address = _polkadotCrypto!.encodeAddress(kp.publicKey, 42); + } catch { + // Fallback: use polkadot's sr25519Sign directly + sig = _polkadotCrypto!.sr25519Sign( + msg, + kp as unknown as { publicKey: Uint8Array; secretKey: Uint8Array }, + ); + address = _polkadotCrypto!.encodeAddress((kp as unknown as { publicKey: Uint8Array }).publicKey, 42); + } + + const sigHex = '0x' + Buffer.from(sig).toString('hex'); + return { + namespace, + namespace_hotkey: address, + namespace_sig: sigHex, + namespace_timestamp_ms: ts, + }; + } + + // No keypair: use plain namespace_key + return { + namespace, + namespace_key: namespaceKey || '', + }; +} + +// --------------------------------------------------------------------------- +// X25519 encryption helpers (Node.js built-in crypto) +// --------------------------------------------------------------------------- + +/** + * Derive a shared secret using X25519 ECDH. + * Returns 32-byte shared secret as Buffer. + */ +export function x25519SharedSecret( + privateKey: Buffer, + peerPublicKey: Buffer, +): Buffer { + const ecdh = require('crypto').createECDH('x25519'); + ecdh.setPrivateKey(privateKey); + return ecdh.computeSecret(peerPublicKey); +} + +/** + * Generate an X25519 key pair. + * Returns { publicKey: Buffer, privateKey: Buffer }. + */ +export function x25519GenerateKeyPair(): { publicKey: Buffer; privateKey: Buffer } { + const ecdh = require('crypto').createECDH('x25519'); + ecdh.generateKeys(); + return { + publicKey: ecdh.getPublicKey(), + privateKey: ecdh.getPrivateKey(), + }; +} + +/** + * Encrypt data using AES-256-GCM with a shared secret. + * Returns { ciphertext, iv, tag } as hex strings. + */ +export function aesGcmEncrypt( + sharedSecret: Buffer, + plaintext: string, +): { ciphertext: string; iv: string; tag: string } { + const crypto = require('crypto'); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', sharedSecret.slice(0, 32), iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return { + ciphertext: encrypted.toString('hex'), + iv: iv.toString('hex'), + tag: tag.toString('hex'), + }; +} + +/** + * Decrypt data using AES-256-GCM with a shared secret. + */ +export function aesGcmDecrypt( + sharedSecret: Buffer, + ciphertext: string, + iv: string, + tag: string, +): string { + const crypto = require('crypto'); + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + sharedSecret.slice(0, 32), + Buffer.from(iv, 'hex'), + ); + decipher.setAuthTag(Buffer.from(tag, 'hex')); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(ciphertext, 'hex')), + decipher.final(), + ]); + return decrypted.toString('utf-8'); +} diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts new file mode 100644 index 00000000..921db943 --- /dev/null +++ b/sdk/typescript/src/errors.ts @@ -0,0 +1,43 @@ +/** + * Exception classes for Engram SDK. + */ + +/** Base error for all Engram-related failures. */ +export class EngramError extends Error { + constructor(message: string) { + super(message); + this.name = 'EngramError'; + } +} + +/** Raised when the miner is unreachable or returns HTTP errors. */ +export class MinerOfflineError extends EngramError { + constructor(message: string) { + super(`Miner offline: ${message}`); + this.name = 'MinerOfflineError'; + } +} + +/** Raised when an ingest operation fails. */ +export class IngestError extends EngramError { + constructor(message: string) { + super(`Ingest failed: ${message}`); + this.name = 'IngestError'; + } +} + +/** Raised when a query operation fails. */ +export class QueryError extends EngramError { + constructor(message: string) { + super(`Query failed: ${message}`); + this.name = 'QueryError'; + } +} + +/** Raised when a CID is malformed or not found. */ +export class InvalidCIDError extends EngramError { + constructor(message: string) { + super(`Invalid CID: ${message}`); + this.name = 'InvalidCIDError'; + } +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts new file mode 100644 index 00000000..4d8ebe24 --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,28 @@ +/** + * @engram/client — TypeScript SDK for Engram decentralized knowledge graph. + */ + +export { EngramClient } from './client.js'; +export { + EngramError, + MinerOfflineError, + IngestError, + QueryError, + InvalidCIDError, +} from './errors.js'; +export { namespaceAuth, isSr25519Available } from './crypto.js'; +export type { + ApiResponse, + BatchIngestOptions, + ConversationMessage, + EngramClientOptions, + Filter, + HealthResponse, + ImageIngestResult, + IngestOptions, + Metadata, + PdfIngestResult, + QueryResult, + EngramRecord, + UrlIngestResult, +} from './types.js'; diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts new file mode 100644 index 00000000..1241fbbb --- /dev/null +++ b/sdk/typescript/src/types.ts @@ -0,0 +1,88 @@ +/** + * TypeScript type definitions for Engram SDK. + */ + +/** Metadata associated with a knowledge record. */ +export interface Metadata { + [key: string]: unknown; +} + +/** Result returned by query / query_by_vector. */ +export interface QueryResult { + cid: string; + score: number; + metadata: Metadata; +} + +/** A single knowledge record from list / get. */ +export interface EngramRecord { + cid: string; + metadata: Metadata; +} + +/** Health check response. */ +export interface HealthResponse { + status: string; + vectors: number; + uid: string; +} + +/** Generic API response wrapper. */ +export interface ApiResponse { + [key: string]: unknown; +} + +/** Ingestion options shared across ingest methods. */ +export interface IngestOptions { + metadata?: Metadata; +} + +/** Response from ingest_image. */ +export interface ImageIngestResult { + cid: string; + description: string; + content_cid: string; + filename: string; +} + +/** Response from ingest_pdf. */ +export interface PdfIngestResult { + cid: string; + pages: number; + chars: number; + content_cid: string; + filename: string; +} + +/** Response from ingest_url. */ +export interface UrlIngestResult { + cid: string; + url: string; + title: string; + chars: number; +} + +/** A single message in a conversation. */ +export interface ConversationMessage { + role: 'user' | 'assistant' | 'system'; + content: string; +} + +/** Batch ingest file options. */ +export interface BatchIngestOptions { + return_errors?: boolean; +} + +/** Filter for queries and lists. */ +export interface Filter { + [key: string]: unknown; +} + +/** Constructor options for EngramClient. */ +export interface EngramClientOptions { + miner_url?: string; + timeout?: number; + namespace?: string; + namespace_key?: string; + keypair?: unknown; // sr25519 keypair from @polkadot/util-crypto +} diff --git a/sdk/typescript/tests/client.test.ts b/sdk/typescript/tests/client.test.ts new file mode 100644 index 00000000..1f799aba --- /dev/null +++ b/sdk/typescript/tests/client.test.ts @@ -0,0 +1,326 @@ +/** + * Unit tests for EngramClient. + * + * All HTTP calls are mocked via _setHttpAgent. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { EngramClient } from '../src/client.js'; +import { + EngramError, + MinerOfflineError, + IngestError, + QueryError, + InvalidCIDError, +} from '../src/errors.js'; +import type { ApiResponse, Metadata, EngramRecord } from '../src/types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMockFetch(responses: ApiResponse[]) { + let idx = 0; + return async (_url: string, _init: RequestInit): Promise => { + const data = responses[idx++] ?? {}; + return { + ok: true, + status: 200, + json: async () => data, + } as Response; + }; +} + +function makeErrorFetch(status: number, message: string) { + return async (_url: string, _init: RequestInit): Promise => { + return { + ok: false, + status, + statusText: message, + json: async () => ({ error: message }), + } as Response; + }; +} + +function makeClient(responses: ApiResponse[]): EngramClient { + const client = new EngramClient({ miner_url: 'http://test:8091' }); + client._setHttpAgent(makeMockFetch(responses)); + return client; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('EngramClient', () => { + // ---- Constructor ---- + describe('constructor', () => { + it('should use default values', () => { + const c = new EngramClient(); + expect(c.minerUrl).toBe('http://127.0.0.1:8091'); + expect(c.timeout).toBe(30_000); + expect(c.namespace).toBeUndefined(); + }); + + it('should strip trailing slash from miner_url', () => { + const c = new EngramClient({ miner_url: 'http://example.com/' }); + expect(c.minerUrl).toBe('http://example.com'); + }); + + it('should accept custom timeout', () => { + const c = new EngramClient({ timeout: 5000 }); + expect(c.timeout).toBe(5000); + }); + + it('should store namespace options', () => { + const c = new EngramClient({ + namespace: 'myns', + namespace_key: 'key123', + }); + expect(c.namespace).toBe('myns'); + }); + }); + + // ---- Error classes ---- + describe('error classes', () => { + it('EngramError is base', () => { + const e = new EngramError('test'); + expect(e).toBeInstanceOf(Error); + expect(e.name).toBe('EngramError'); + }); + + it('MinerOfflineError extends EngramError', () => { + const e = new MinerOfflineError('test'); + expect(e).toBeInstanceOf(EngramError); + expect(e.message).toContain('Miner offline'); + }); + + it('IngestError extends EngramError', () => { + const e = new IngestError('test'); + expect(e).toBeInstanceOf(EngramError); + expect(e.message).toContain('Ingest failed'); + }); + + it('QueryError extends EngramError', () => { + const e = new QueryError('test'); + expect(e).toBeInstanceOf(EngramError); + expect(e.message).toContain('Query failed'); + }); + + it('InvalidCIDError extends EngramError', () => { + const e = new InvalidCIDError('test'); + expect(e).toBeInstanceOf(EngramError); + expect(e.message).toContain('Invalid CID'); + }); + }); + + // ---- ingest ---- + describe('ingest', () => { + it('returns CID on success', async () => { + const c = makeClient([{ cid: 'abc123' }]); + const cid = await c.ingest('Hello world'); + expect(cid).toBe('abc123'); + }); + + it('throws IngestError when miner returns error', async () => { + const c = makeClient([{ error: 'bad payload' }]); + await expect(c.ingest('x')).rejects.toThrow(IngestError); + }); + + it('throws IngestError when CID is missing', async () => { + const c = makeClient([{}]); + await expect(c.ingest('x')).rejects.toThrow(IngestError); + }); + }); + + // ---- ingestEmbedding ---- + describe('ingestEmbedding', () => { + it('returns CID on success', async () => { + const c = makeClient([{ cid: 'emb456' }]); + const cid = await c.ingestEmbedding([0.1, 0.2, 0.3]); + expect(cid).toBe('emb456'); + }); + }); + + // ---- query ---- + describe('query', () => { + it('returns results array', async () => { + const results = [ + { cid: 'a', score: 0.9, metadata: {} }, + { cid: 'b', score: 0.8, metadata: {} }, + ]; + const c = makeClient([{ results }]); + const got = await c.query('search text', 5); + expect(got).toEqual(results); + }); + + it('throws QueryError on error', async () => { + const c = makeClient([{ error: 'search failed' }]); + await expect(c.query('x')).rejects.toThrow(QueryError); + }); + + it('returns empty array when results missing', async () => { + const c = makeClient([{}]); + const got = await c.query('x'); + expect(got).toEqual([]); + }); + }); + + // ---- queryByVector ---- + describe('queryByVector', () => { + it('returns results', async () => { + const results = [{ cid: 'v1', score: 1.0, metadata: {} }]; + const c = makeClient([{ results }]); + const got = await c.queryByVector([0.5, 0.5]); + expect(got).toEqual(results); + }); + }); + + // ---- get ---- + describe('get', () => { + it('returns record', async () => { + const c = makeClient([{ cid: 'rec1', metadata: { foo: 'bar' } }]); + const rec = await c.get('rec1'); + expect(rec.cid).toBe('rec1'); + expect(rec.metadata).toEqual({ foo: 'bar' }); + }); + + it('throws InvalidCIDError on error', async () => { + const c = makeClient([{ error: 'not found' }]); + await expect(c.get('bad')).rejects.toThrow(InvalidCIDError); + }); + }); + + // ---- delete ---- + describe('delete', () => { + it('returns true on success', async () => { + const c = makeClient([{}]); + const ok = await c.delete('rec1'); + expect(ok).toBe(true); + }); + + it('throws InvalidCIDError on error', async () => { + const c = makeClient([{ error: 'gone' }]); + await expect(c.delete('x')).rejects.toThrow(InvalidCIDError); + }); + }); + + // ---- list ---- + describe('list', () => { + it('returns records', async () => { + const records = [ + { cid: 'r1', metadata: {} }, + { cid: 'r2', metadata: {} }, + ]; + const c = makeClient([{ records }]); + const got = await c.list(); + expect(got).toEqual(records); + }); + + it('throws QueryError on error', async () => { + const c = makeClient([{ error: 'list failed' }]); + await expect(c.list()).rejects.toThrow(QueryError); + }); + }); + + // ---- health ---- + describe('health', () => { + it('returns health object', async () => { + const c = makeClient([{ status: 'ok', vectors: 42, uid: 'abc' }]); + const h = await c.health(); + expect(h.status).toBe('ok'); + expect(h.vectors).toBe(42); + expect(h.uid).toBe('abc'); + }); + }); + + // ---- isOnline ---- + describe('isOnline', () => { + it('returns true when healthy', async () => { + const c = makeClient([{ status: 'ok', vectors: 0, uid: '' }]); + const ok = await c.isOnline(); + expect(ok).toBe(true); + }); + + it('returns false when offline', async () => { + const c = new EngramClient({ miner_url: 'http://offline:9999' }); + c._setHttpAgent(async () => { + throw new Error('Connection refused'); + }); + const ok = await c.isOnline(); + expect(ok).toBe(false); + }); + }); + + // ---- MinerOfflineError ---- + describe('network errors', () => { + it('throws MinerOfflineError when fetch fails', async () => { + const c = new EngramClient({ miner_url: 'http://dead:9999' }); + c._setHttpAgent(async () => { + throw new Error('ECONNREFUSED'); + }); + await expect(c.health()).rejects.toThrow(MinerOfflineError); + }); + + it('throws MinerOfflineError on HTTP error', async () => { + const c = new EngramClient({ miner_url: 'http://bad:9999' }); + c._setHttpAgent(makeErrorFetch(500, 'Internal Error')); + await expect(c.health()).rejects.toThrow(MinerOfflineError); + }); + }); + + // ---- ingestConversation ---- + describe('ingestConversation', () => { + it('ingests each message + summary', async () => { + const responses: ApiResponse[] = [ + { cid: 'm1' }, + { cid: 'm2' }, + { cid: 'summary' }, + ]; + const c = makeClient(responses); + const cids = await c.ingestConversation([ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi there' }, + ]); + expect(cids).toEqual(['m1', 'm2', 'summary']); + }); + }); + + // ---- ingestUrl ---- + describe('ingestUrl', () => { + it('fetches and ingests URL content', async () => { + const c = new EngramClient({ miner_url: 'http://test' }); + + const htmlContent = 'Test Page

Hello world!

'; + + // Mock _httpAgent for the ingest POST + c._setHttpAgent(async (_url: string, _init: RequestInit) => { + return { + ok: true, + status: 200, + json: async () => ({ cid: 'url123' }), + } as Response; + }); + + // Mock global fetch for the URL content fetch + const origFetch = globalThis.fetch; + globalThis.fetch = async (_url: string | URL | Request, _init?: RequestInit) => { + return { + ok: true, + status: 200, + text: async () => htmlContent, + } as Response; + }; + + try { + const result = await c.ingestUrl('http://example.com'); + expect(result.cid).toBe('url123'); + expect(result.url).toBe('http://example.com'); + expect(result.title).toBe('Test Page'); + expect(result.chars).toBeGreaterThan(0); + } finally { + globalThis.fetch = origFetch; + } + }); + }); +}); diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json new file mode 100644 index 00000000..a85413f4 --- /dev/null +++ b/sdk/typescript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/sdk/typescript/vitest.config.ts b/sdk/typescript/vitest.config.ts new file mode 100644 index 00000000..8996a048 --- /dev/null +++ b/sdk/typescript/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +});