/** * Wicked Memory: reference client (TypeScript). One file, copy it into your project. * * npm i viem # required (wallet signing) * npm i @x402/fetch @x402/evm # only if you want search to pay per call automatically * * Every call is signed with your wallet (EIP-191 personal_sign over method + path + body + timestamp + * nonce); the wallet decides WHOSE memories you touch. Search is the only metered call: free with an * apiKey, otherwise it pays 0.001 USDC per search via x402 from the same wallet (needs USDC on Base). * * import { WickedMemory } from "./wicked-memory"; * const mem = new WickedMemory({ privateKey: process.env.AGENT_KEY as `0x${string}`, apiKey: "wm_..." }); * const { data } = await mem.store("The deploy runbook says migrate before start.", { tags: ["ops"] }); * const hits = await mem.search("how do we deploy?"); */ import { createHash, randomBytes } from "node:crypto"; import { privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; export interface WickedMemoryOptions { /** The agent's wallet private key (0x...). Never sent anywhere; only used to sign. */ privateKey: `0x${string}`; /** Default: the host this file was downloaded from (mainnet: memory.wickedapi.com, testnet: memory-testnet.wickedapi.com). */ baseUrl?: string; /** Free-tier key for search. Without one, search pays via x402 (if `pay` is true). */ apiKey?: string; /** Pay for search with x402 when no apiKey is set. Default true. Requires @x402/fetch and @x402/evm. */ pay?: boolean; /** CAIP-2 network the payment is made on. Default: eip155:8453 for mainnet host, eip155:84532 for testnet host. */ network?: `${string}:${string}`; } export interface Memory { id: string; content: string; tags: string[]; metadata: Record; source: string | null; valid_from: string; valid_until: string | null; superseded_by: string | null; created_at: string; score?: number; } export class WickedMemoryError extends Error { constructor(public status: number, message: string, public body: unknown) { super(`Wicked Memory ${status}: ${message}`); } } const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); export class WickedMemory { readonly account: PrivateKeyAccount; readonly baseUrl: string; private apiKey?: string; private pay: boolean; private network: `${string}:${string}`; private payFetch?: typeof fetch; constructor(opts: WickedMemoryOptions) { this.account = privateKeyToAccount(opts.privateKey); this.baseUrl = (opts.baseUrl ?? "https://memory.wickedapi.com").replace(/\/$/, ""); this.apiKey = opts.apiKey; this.pay = opts.pay ?? true; this.network = opts.network ?? (this.baseUrl.includes("testnet") ? "eip155:84532" : "eip155:8453"); } /** The wallet address your memories are scoped to. */ get address() { return this.account.address; } private async request(method: string, path: string, json?: unknown, extra: Record = {}, f: typeof fetch = fetch): Promise { const body = json === undefined ? "" : JSON.stringify(json); const timestamp = String(Math.floor(Date.now() / 1000)); const nonce = randomBytes(16).toString("hex"); const message = [ "Wicked Memory auth", `wallet: ${this.account.address.toLowerCase()}`, `method: ${method.toUpperCase()}`, `path: ${path}`, `timestamp: ${timestamp}`, `nonce: ${nonce}`, `body-sha256: ${sha256(body)}`, ].join("\n"); const signature = await this.account.signMessage({ message }); const res = await f(this.baseUrl + path, { method, headers: { "x-wallet-address": this.account.address, "x-wallet-timestamp": timestamp, "x-wallet-nonce": nonce, "x-wallet-signature": signature, ...(body ? { "content-type": "application/json" } : {}), ...extra, }, body: body || undefined, }); const parsed = await res.json().catch(() => null); if (!res.ok) throw new WickedMemoryError(res.status, (parsed as any)?.error ?? res.statusText, parsed); return parsed as T; } /** Store a memory (free). A real embedding is computed at write time. */ store(content: string, opts: { tags?: string[]; metadata?: Record; source?: string } = {}) { return this.request<{ data: Memory }>("POST", "/memories", { content, ...opts }); } /** Semantic search over YOUR memories. Free with an apiKey; otherwise pays 0.001 USDC via x402. */ async search( q: string, opts: { limit?: number; tags?: string[]; createdAfter?: string; createdBefore?: string; asOf?: string; includeSuperseded?: boolean } = {}, ) { const p = new URLSearchParams({ q }); if (opts.limit) p.set("limit", String(opts.limit)); if (opts.tags?.length) p.set("tags", opts.tags.join(",")); if (opts.createdAfter) p.set("created_after", opts.createdAfter); if (opts.createdBefore) p.set("created_before", opts.createdBefore); if (opts.asOf) p.set("as_of", opts.asOf); if (opts.includeSuperseded !== undefined) p.set("include_superseded", String(opts.includeSuperseded)); // URLSearchParams encodes spaces as "+"; the signed path must match the sent path exactly, and we send // and sign the same string, so either encoding is fine as long as it is identical in both places. const path = `/memories/search?${p.toString()}`; if (this.apiKey) return this.request<{ data: Memory[] }>("GET", path, undefined, { "x-api-key": this.apiKey }); if (!this.pay) return this.request<{ data: Memory[] }>("GET", path); return this.request<{ data: Memory[] }>("GET", path, undefined, {}, await this.getPayFetch()); } get(id: string) { return this.request<{ data: Memory }>("GET", `/memories/${id}`); } /** Update WITHOUT overwriting: creates a new version and closes the old one. Omitted fields carry over. */ update(id: string, patch: { content?: string; tags?: string[]; metadata?: Record; source?: string }) { return this.request<{ data: { previous: Memory; current: Memory } }>("PATCH", `/memories/${id}`, patch); } /** Full version chain, oldest first. */ history(id: string) { return this.request<{ data: Memory[] }>("GET", `/memories/${id}/history`); } /** PERMANENT hard delete. scope "chain" (default) removes every version; "version" just this one. */ remove(id: string, opts: { scope?: "chain" | "version"; reason?: string } = {}) { const p = new URLSearchParams({ scope: opts.scope ?? "chain" }); if (opts.reason) p.set("reason", opts.reason); return this.request<{ data: { deleted: string[]; scope: string } }>("DELETE", `/memories/${id}?${p.toString()}`); } /** Your deletion audit log (ids, times, reasons; never content). */ deletions() { return this.request<{ data: { id: string; memory_id: string; deleted_at: string; reason: string | null }[] }>("GET", "/memory-deletions"); } private async getPayFetch(): Promise { if (!this.payFetch) { let fetchMod: any, evmMod: any; try { fetchMod = await import("@x402/fetch"); evmMod = await import("@x402/evm/exact/client"); } catch { throw new Error("Paid search needs `npm i @x402/fetch @x402/evm`, or pass an apiKey, or set pay:false."); } const client = new fetchMod.x402Client().register(this.network, new evmMod.ExactEvmScheme(this.account)); this.payFetch = fetchMod.wrapFetchWithPayment(fetch, client) as typeof fetch; } return this.payFetch; } }