diff --git a/catalog.json b/catalog.json index a9ce7f6..436e49d 100644 --- a/catalog.json +++ b/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "providerId": "official", "name": "PI-Desktop Official Plugins", - "updatedAt": "2026-09-20T15:44:14Z", + "updatedAt": "2026-09-20T17:25:34Z", "homepage": "https://github.com/vastsa/pi-desktop-plugins", "plugins": [ { @@ -150,6 +150,51 @@ } ] }, + { + "id": "in.memcode.memory", + "name": "Memcode Memory", + "description": "Give PI-Desktop agents explicit tools to save, search, and retrieve the signed-in user's Memcode memories.", + "i18n": { + "en": { + "name": "Memcode Memory", + "description": "Give PI-Desktop agents explicit tools to save, search, and retrieve the signed-in user's Memcode memories.", + "safetyNotes": "Makes bounded requests only to memory.memcode.in. Reads MEMCODE_API_KEY from the PI-Desktop process environment and never stores or returns it. Saving memory is persistent and requires PI-Desktop's high-risk tool approval." + }, + "zh-CN": { + "name": "Memcode \u8bb0\u5fc6", + "description": "\u4e3a PI-Desktop Agent \u63d0\u4f9b\u660e\u786e\u7684\u5de5\u5177\uff0c\u7528\u4e8e\u4fdd\u5b58\u3001\u641c\u7d22\u548c\u68c0\u7d22\u5f53\u524d\u7528\u6237\u7684 Memcode \u8bb0\u5fc6\u3002", + "safetyNotes": "\u4ec5\u5411 memory.memcode.in \u53d1\u8d77\u53d7\u9650\u8bf7\u6c42\u3002\u4ece PI-Desktop \u8fdb\u7a0b\u73af\u5883\u8bfb\u53d6 MEMCODE_API_KEY\uff0c\u7edd\u4e0d\u4fdd\u5b58\u6216\u8fd4\u56de\u8be5\u5bc6\u94a5\u3002\u4fdd\u5b58\u8bb0\u5fc6\u662f\u6301\u4e45\u5316\u64cd\u4f5c\uff0c\u5e76\u53d7 PI-Desktop \u9ad8\u98ce\u9669\u5de5\u5177\u5ba1\u6279\u4fdd\u62a4\u3002" + } + }, + "author": "Memcode", + "categories": [ + "productivity", + "ai", + "memory" + ], + "verified": true, + "downloads": 0, + "homepage": "https://github.com/vastsa/pi-desktop-plugins/tree/main/plugins/in.memcode.memory", + "repository": "https://github.com/vastsa/pi-desktop-plugins", + "readmeMarkdown": "# Memcode Memory\n\nAdds explicit Memcode long-term-memory tools to PI-Desktop agents. The plugin\nuses Memcode's personal v2 API, so the API credential determines the user; no\nuser ID or integration attribution is accepted from agent arguments.\n\n## Setup\n\nSet the credential in the environment that starts PI-Desktop, then restart the\napp:\n\n```bash\nexport MEMCODE_API_KEY=your_memcode_api_key\n```\n\nThe plugin never persists this value in PI-Desktop settings. Start with\n`memcode_test_connection`, then explicitly approve memory writes when PI asks.\n\n## Tools\n\n| Tool | Risk | Operation |\n| --- | --- | --- |\n| `memcode_test_connection` | Medium | `GET /v2/test`; validates the configured credential without reading memory |\n| `memcode_save_memory` | High | `POST /v2/memory/ingest`; persists user-approved text and returns an asynchronous job receipt |\n| `memcode_search_memories` | Medium | `POST /v2/memory/search`; searches the authenticated user's memory/chunk index |\n| `memcode_retrieve_answer` | Medium | `POST /v2/memory/retrieve`; returns a grounded answer and source records |\n\nPI-Desktop exposes these under its normal plugin-prefixed tool names.\n\n## Capability and data-flow review\n\n| Entry point | Data read | Destination | Permission | Confirmation/bounds | Cleanup |\n| --- | --- | --- | --- | --- | --- |\n| All tools | `MEMCODE_API_KEY` and tool arguments | `https://memory.memcode.in` only | `net.fetch` | PI install grant; 20s timeout; 256 KiB response cap; no redirects outside allowlist | No background task or retained token |\n| Save tool | User-approved text and optional assistant response | `/v2/memory/ingest` | `agent.tool.register`, `net.fetch` | High-risk agent-tool approval; 20,000-character fields | Durable data follows the user's Memcode retention settings |\n| Search/retrieve | Query and bounded numeric options | `/v2/memory/search`, `/v2/memory/retrieve` | `agent.tool.register`, `net.fetch` | Medium-risk tool policy; query and result bounds | No plugin-local cache |\n\nThe plugin has no third-party runtime dependencies, native code, dynamic code,\nfilesystem access, telemetry, retries, timers, or background services. It does\nnot send client-provided attribution headers or metadata; Memcode assigns any\nintegration attribution server-side.\n\n## Failure behavior\n\n- Missing or malformed credentials fail before the network call.\n- HTTP errors expose only the status (and bounded `Retry-After` for 429), not\n response bodies that could contain sensitive details.\n- Malformed or oversized JSON responses are rejected.\n- The plugin never retries a write automatically.\n- Unload unregisters every tool, including after a partially failed load.\n\nDeleting memories is intentionally outside this first version because\nPI-Desktop agent tools must not perform destructive remote actions without a\nseparate, explicit design and confirmation path.\n", + "safetyNotes": "Makes bounded requests only to memory.memcode.in. Reads MEMCODE_API_KEY from the PI-Desktop process environment and never stores or returns it. Saving memory is persistent and requires PI-Desktop's high-risk tool approval.", + "versions": [ + { + "version": "0.1.0", + "publishedAt": "2026-09-20T17:25:34Z", + "changelog": "Initial release with credential verification, durable memory ingest, semantic search, and grounded retrieval tools.", + "minPiDesktop": ">=0.2.0", + "shasum": "c77e6aebaed1f09d9fb82568028b40910c97853fa289b114e819454279b54672", + "url": "packages/in.memcode.memory-0.1.0.piplug", + "sizeBytes": 14955, + "permissions": [ + "agent.tool.register", + "net.fetch" + ], + "fs": {} + } + ] + }, { "id": "io.github.liushunqiu.pi-idea-git", "name": "IDEA Git", diff --git a/packages/in.memcode.memory-0.1.0.piplug b/packages/in.memcode.memory-0.1.0.piplug new file mode 100644 index 0000000..8c5972e Binary files /dev/null and b/packages/in.memcode.memory-0.1.0.piplug differ diff --git a/plugins/in.memcode.memory/README.md b/plugins/in.memcode.memory/README.md new file mode 100644 index 0000000..491f8af --- /dev/null +++ b/plugins/in.memcode.memory/README.md @@ -0,0 +1,58 @@ +# Memcode Memory + +Adds explicit Memcode long-term-memory tools to PI-Desktop agents. The plugin +uses Memcode's personal v2 API, so the API credential determines the user; no +user ID or integration attribution is accepted from agent arguments. + +## Setup + +Open the [Memcode API-key dashboard](https://app.memcode.in/dashboard?section=api-keys&integration=pi-desktop) and create a +key with **PI-Desktop** selected under integration attribution. Set that key in +the environment that starts PI-Desktop, then restart the app: + +```bash +export MEMCODE_API_KEY=your_memcode_api_key +``` + +Memcode binds the `pi-desktop` identity when the key is issued; the plugin does +not send an attribution header or metadata field. A generic personal key still +works, but its traffic is counted as generic direct API usage. The plugin never +persists this value in PI-Desktop settings. Start with +`memcode_test_connection`, then explicitly approve memory writes when PI asks. + +## Tools + +| Tool | Risk | Operation | +| --- | --- | --- | +| `memcode_test_connection` | Medium | `GET /v2/test`; validates the configured credential without reading memory | +| `memcode_save_memory` | High | `POST /v2/memory/ingest`; persists user-approved text and returns an asynchronous job receipt | +| `memcode_search_memories` | Medium | `POST /v2/memory/search`; searches the authenticated user's memory/chunk index | +| `memcode_retrieve_answer` | Medium | `POST /v2/memory/retrieve`; returns a grounded answer and source records | + +PI-Desktop exposes these under its normal plugin-prefixed tool names. + +## Capability and data-flow review + +| Entry point | Data read | Destination | Permission | Confirmation/bounds | Cleanup | +| --- | --- | --- | --- | --- | --- | +| All tools | `MEMCODE_API_KEY` and tool arguments | `https://memory.memcode.in` only | `net.fetch` | PI install grant; 20s timeout; 256 KiB response cap; no redirects outside allowlist | No background task or retained token | +| Save tool | User-approved text and optional assistant response | `/v2/memory/ingest` | `agent.tool.register`, `net.fetch` | High-risk agent-tool approval; 20,000-character fields | Durable data follows the user's Memcode retention settings | +| Search/retrieve | Query and bounded numeric options | `/v2/memory/search`, `/v2/memory/retrieve` | `agent.tool.register`, `net.fetch` | Medium-risk tool policy; query and result bounds | No plugin-local cache | + +The plugin has no third-party runtime dependencies, native code, dynamic code, +filesystem access, telemetry, retries, timers, or background services. It does +not send client-provided attribution headers or metadata; Memcode assigns any +integration attribution server-side. + +## Failure behavior + +- Missing or malformed credentials fail before the network call. +- HTTP errors expose only the status (and bounded `Retry-After` for 429), not + response bodies that could contain sensitive details. +- Malformed or oversized JSON responses are rejected. +- The plugin never retries a write automatically. +- Unload unregisters every tool, including after a partially failed load. + +Deleting memories is intentionally outside this first version because +PI-Desktop agent tools must not perform destructive remote actions without a +separate, explicit design and confirmation path. diff --git a/plugins/in.memcode.memory/main.js b/plugins/in.memcode.memory/main.js new file mode 100644 index 0000000..66de74d --- /dev/null +++ b/plugins/in.memcode.memory/main.js @@ -0,0 +1,196 @@ +const API_ORIGIN = "https://memory.memcode.in"; +const RESPONSE_LIMIT = 256 * 1024; +const registeredTools = []; + +function requireApiKey() { + const value = String(process.env.MEMCODE_API_KEY || "").trim(); + if (!value) { + throw new Error("MEMCODE_API_KEY is not configured for PI-Desktop."); + } + if (value.length > 16384 || /[\r\n]/.test(value)) { + throw new Error("MEMCODE_API_KEY is invalid."); + } + return value; +} + +function requiredString(value, name, maxLength) { + const normalized = String(value ?? "").trim(); + if (!normalized) throw new Error(`${name} is required.`); + if (normalized.length > maxLength) throw new Error(`${name} exceeds ${maxLength} characters.`); + return normalized; +} + +function optionalString(value, name, maxLength) { + if (value === undefined || value === null || value === "") return undefined; + return requiredString(value, name, maxLength); +} + +function optionalInteger(value, name, minimum, maximum) { + if (value === undefined || value === null) return undefined; + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new Error(`${name} must be an integer between ${minimum} and ${maximum}.`); + } + return value; +} + +function optionalNumber(value, name, minimum, maximum) { + if (value === undefined || value === null) return undefined; + if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) { + throw new Error(`${name} must be between ${minimum} and ${maximum}.`); + } + return value; +} + +function optionalEnum(value, name, values) { + if (value === undefined || value === null) return undefined; + if (!values.includes(value)) throw new Error(`${name} must be one of: ${values.join(", ")}.`); + return value; +} + +function compact(object) { + return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined)); +} + +async function memcodeRequest(path, { method = "GET", body, headers = {} } = {}) { + const apiKey = requireApiKey(); + const response = await pi.net.fetch({ + url: `${API_ORIGIN}${path}`, + method, + headers: { + accept: "application/json", + authorization: `Bearer ${apiKey}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + ...headers, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + timeoutMs: 20000, + }); + + if (response.status < 200 || response.status >= 300) { + const retryAfter = response.headers?.["retry-after"] || response.headers?.["Retry-After"]; + const suffix = response.status === 429 && retryAfter ? ` Retry after ${String(retryAfter).slice(0, 32)}.` : ""; + throw new Error(`Memcode request failed with HTTP ${response.status}.${suffix}`); + } + const bodyText = String(response.bodyText || ""); + if (bodyText.length > RESPONSE_LIMIT) throw new Error("Memcode response exceeded the 256 KiB limit."); + let payload; + try { + payload = bodyText ? JSON.parse(bodyText) : {}; + } catch { + throw new Error("Memcode returned an invalid JSON response."); + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("Memcode returned an invalid response shape."); + } + return payload.data && typeof payload.data === "object" ? payload.data : payload; +} + +const tools = [ + { + name: "memcode_test_connection", + description: "Verify that the configured Memcode credential is valid without reading or writing memories.", + risk: "medium", + schema: { type: "object", properties: {}, additionalProperties: false }, + execute: async () => memcodeRequest("/v2/test"), + }, + { + name: "memcode_save_memory", + description: "Persist user-approved text to the authenticated user's Memcode long-term memory.", + risk: "high", + schema: { + type: "object", + properties: { + text: { type: "string", minLength: 1, maxLength: 20000 }, + agent_response: { type: "string", maxLength: 20000 }, + effort_level: { type: "string", enum: ["low", "high"] }, + forget: { type: "boolean" }, + idempotency_key: { type: "string", minLength: 1, maxLength: 256 }, + }, + required: ["text"], + additionalProperties: false, + }, + execute: async (args = {}) => { + const idempotencyKey = optionalString(args.idempotency_key, "idempotency_key", 256); + return memcodeRequest("/v2/memory/ingest", { + method: "POST", + headers: idempotencyKey ? { "idempotency-key": idempotencyKey } : {}, + body: compact({ + user_query: requiredString(args.text, "text", 20000), + agent_response: optionalString(args.agent_response, "agent_response", 20000), + effort_level: optionalEnum(args.effort_level, "effort_level", ["low", "high"]) || "low", + forget: args.forget === undefined ? false : Boolean(args.forget), + }), + }); + }, + }, + { + name: "memcode_search_memories", + description: "Search the authenticated user's Memcode memories and original stored chunks.", + risk: "medium", + schema: { + type: "object", + properties: { + query: { type: "string", minLength: 1, maxLength: 4000 }, + mode: { type: "string", enum: ["default", "chunks", "memories"] }, + top_k: { type: "integer", minimum: 1, maximum: 100 }, + original_top_k: { type: "integer", minimum: 1, maximum: 100 }, + include_original_chunks: { type: "boolean" }, + search_mode: { type: "string", enum: ["default", "global"] }, + minimum_score: { type: "number", minimum: 0, maximum: 1 }, + }, + required: ["query"], + additionalProperties: false, + }, + execute: async (args = {}) => memcodeRequest("/v2/memory/search", { + method: "POST", + body: compact({ + query: requiredString(args.query, "query", 4000), + mode: optionalEnum(args.mode, "mode", ["default", "chunks", "memories"]) || "default", + top_k: optionalInteger(args.top_k, "top_k", 1, 100) || 10, + original_top_k: optionalInteger(args.original_top_k, "original_top_k", 1, 100) || 10, + include_original_chunks: args.include_original_chunks === undefined ? true : Boolean(args.include_original_chunks), + search_mode: optionalEnum(args.search_mode, "search_mode", ["default", "global"]) || "default", + minimum_score: optionalNumber(args.minimum_score, "minimum_score", 0, 1) ?? 0, + }), + }), + }, + { + name: "memcode_retrieve_answer", + description: "Answer a question from the authenticated user's Memcode memories with source records.", + risk: "medium", + schema: { + type: "object", + properties: { + query: { type: "string", minLength: 1, maxLength: 4000 }, + top_k: { type: "integer", minimum: 1, maximum: 50 }, + }, + required: ["query"], + additionalProperties: false, + }, + execute: async (args = {}) => memcodeRequest("/v2/memory/retrieve", { + method: "POST", + body: { + query: requiredString(args.query, "query", 4000), + top_k: optionalInteger(args.top_k, "top_k", 1, 50) || 5, + }, + }), + }, +]; + +async function onLoad() { + try { + for (const tool of tools) { + await pi.agent.registerTool(tool); + registeredTools.push(tool.name); + } + } catch (error) { + await Promise.allSettled(registeredTools.splice(0).map((name) => pi.agent.unregisterTool(name))); + throw error; + } +} + +async function onUnload() { + await Promise.allSettled(registeredTools.splice(0).map((name) => pi.agent.unregisterTool(name))); +} + +module.exports = { onLoad, onUnload, __test: { memcodeRequest, tools } }; diff --git a/plugins/in.memcode.memory/manifest.json b/plugins/in.memcode.memory/manifest.json new file mode 100644 index 0000000..a3d40a8 --- /dev/null +++ b/plugins/in.memcode.memory/manifest.json @@ -0,0 +1,91 @@ +{ + "schemaVersion": 1, + "id": "in.memcode.memory", + "name": "Memcode Memory", + "version": "0.1.0", + "description": "Give PI-Desktop agents explicit tools to save, search, and retrieve the signed-in user's Memcode memories.", + "changelog": "Initial release with credential verification, durable memory ingest, semantic search, and grounded retrieval tools.", + "safetyNotes": "Makes bounded requests only to memory.memcode.in. Reads MEMCODE_API_KEY from the PI-Desktop process environment and never stores or returns it. Saving memory is persistent and requires PI-Desktop's high-risk tool approval.", + "i18n": { + "en": { + "name": "Memcode Memory", + "description": "Give PI-Desktop agents explicit tools to save, search, and retrieve the signed-in user's Memcode memories.", + "safetyNotes": "Makes bounded requests only to memory.memcode.in. Reads MEMCODE_API_KEY from the PI-Desktop process environment and never stores or returns it. Saving memory is persistent and requires PI-Desktop's high-risk tool approval." + }, + "zh-CN": { + "name": "Memcode 记忆", + "description": "为 PI-Desktop Agent 提供明确的工具,用于保存、搜索和检索当前用户的 Memcode 记忆。", + "safetyNotes": "仅向 memory.memcode.in 发起受限请求。从 PI-Desktop 进程环境读取 MEMCODE_API_KEY,绝不保存或返回该密钥。保存记忆是持久化操作,并受 PI-Desktop 高风险工具审批保护。" + } + }, + "author": "Memcode", + "main": "main.js", + "categories": ["productivity", "ai", "memory"], + "contributes": { + "agentTools": [ + { + "name": "memcode_test_connection", + "description": "Verify that the configured Memcode credential is valid without reading or writing memories.", + "risk": "medium", + "schema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + { + "name": "memcode_save_memory", + "description": "Persist user-approved text to the authenticated user's Memcode long-term memory.", + "risk": "high", + "schema": { + "type": "object", + "properties": { + "text": { "type": "string", "minLength": 1, "maxLength": 20000 }, + "agent_response": { "type": "string", "maxLength": 20000 }, + "effort_level": { "type": "string", "enum": ["low", "high"] }, + "forget": { "type": "boolean" }, + "idempotency_key": { "type": "string", "minLength": 1, "maxLength": 256 } + }, + "required": ["text"], + "additionalProperties": false + } + }, + { + "name": "memcode_search_memories", + "description": "Search the authenticated user's Memcode memories and original stored chunks.", + "risk": "medium", + "schema": { + "type": "object", + "properties": { + "query": { "type": "string", "minLength": 1, "maxLength": 4000 }, + "mode": { "type": "string", "enum": ["default", "chunks", "memories"] }, + "top_k": { "type": "integer", "minimum": 1, "maximum": 100 }, + "original_top_k": { "type": "integer", "minimum": 1, "maximum": 100 }, + "include_original_chunks": { "type": "boolean" }, + "search_mode": { "type": "string", "enum": ["default", "global"] }, + "minimum_score": { "type": "number", "minimum": 0, "maximum": 1 } + }, + "required": ["query"], + "additionalProperties": false + } + }, + { + "name": "memcode_retrieve_answer", + "description": "Answer a question from the authenticated user's Memcode memories with source records.", + "risk": "medium", + "schema": { + "type": "object", + "properties": { + "query": { "type": "string", "minLength": 1, "maxLength": 4000 }, + "top_k": { "type": "integer", "minimum": 1, "maximum": 50 } + }, + "required": ["query"], + "additionalProperties": false + } + } + ] + }, + "permissions": ["agent.tool.register", "net.fetch"], + "net": { "domains": ["memory.memcode.in"] }, + "engines": { "piDesktop": ">=0.2.0" } +} diff --git a/tests/memcode-memory.test.mjs b/tests/memcode-memory.test.mjs new file mode 100644 index 0000000..b4a1b09 --- /dev/null +++ b/tests/memcode-memory.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const root = join(import.meta.dirname, "../plugins/in.memcode.memory"); +const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")); + +function harness(t, responses = []) { + const registered = new Map(); + const unregistered = []; + const calls = []; + const previousPi = global.pi; + const previousKey = process.env.MEMCODE_API_KEY; + process.env.MEMCODE_API_KEY = "test-only-key"; + global.pi = { + agent: { + registerTool: async (tool) => registered.set(tool.name, tool), + unregisterTool: async (name) => unregistered.push(name), + }, + net: { + fetch: async (request) => { + calls.push(request); + return responses.shift() || { status: 200, headers: {}, bodyText: '{"status":"ok","data":{}}' }; + }, + }, + }; + delete require.cache[require.resolve(join(root, "main.js"))]; + const plugin = require(join(root, "main.js")); + t.after(() => { + global.pi = previousPi; + if (previousKey === undefined) delete process.env.MEMCODE_API_KEY; + else process.env.MEMCODE_API_KEY = previousKey; + }); + return { plugin, registered, unregistered, calls }; +} + +test("manifest declares only the reviewed network and tool capabilities", () => { + assert.equal(manifest.id, "in.memcode.memory"); + assert.deepEqual(manifest.permissions, ["agent.tool.register", "net.fetch"]); + assert.deepEqual(manifest.net.domains, ["memory.memcode.in"]); + assert.equal(manifest.contributes.agentTools.length, 4); + assert.equal(manifest.contributes.agentTools.find((tool) => tool.name === "memcode_save_memory").risk, "high"); + for (const locale of ["en", "zh-CN"]) { + assert.ok(manifest.i18n[locale].description); + assert.ok(manifest.i18n[locale].safetyNotes); + } +}); + +test("load registers matching tools and unload removes all of them", async (t) => { + const h = harness(t); + await h.plugin.onLoad(); + assert.deepEqual([...h.registered.keys()], manifest.contributes.agentTools.map((tool) => tool.name)); + for (const descriptor of manifest.contributes.agentTools) { + const tool = h.registered.get(descriptor.name); + assert.deepEqual(tool.schema, descriptor.schema); + assert.equal(tool.description, descriptor.description); + assert.equal(tool.risk, descriptor.risk); + } + await h.plugin.onUnload(); + assert.deepEqual(h.unregistered.sort(), [...h.registered.keys()].sort()); +}); + +test("save sends a bounded personal-v2 request without user or attribution fields", async (t) => { + const h = harness(t, [{ + status: 202, + headers: {}, + bodyText: '{"status":"ok","data":{"job_id":"job-1","status":"queued"}}', + }]); + await h.plugin.onLoad(); + const result = await h.registered.get("memcode_save_memory").execute({ + text: "Remember that I prefer concise answers.", + effort_level: "high", + idempotency_key: "turn-1", + }); + assert.deepEqual(result, { job_id: "job-1", status: "queued" }); + const request = h.calls[0]; + assert.equal(request.url, "https://memory.memcode.in/v2/memory/ingest"); + assert.equal(request.headers.authorization, "Bearer test-only-key"); + assert.equal(request.headers["idempotency-key"], "turn-1"); + const body = JSON.parse(request.body); + assert.equal(body.user_query, "Remember that I prefer concise answers."); + assert.equal(body.effort_level, "high"); + assert.equal("user_id" in body, false); + assert.equal(Object.keys(body).some((key) => key.includes("integration") || key.includes("attribution")), false); + assert.doesNotMatch(JSON.stringify(result), /test-only-key/); +}); + +test("missing credential and invalid input fail before egress", async (t) => { + const h = harness(t); + await h.plugin.onLoad(); + delete process.env.MEMCODE_API_KEY; + await assert.rejects( + h.registered.get("memcode_search_memories").execute({ query: "hello" }), + /MEMCODE_API_KEY is not configured/, + ); + process.env.MEMCODE_API_KEY = "test-only-key"; + await assert.rejects( + h.registered.get("memcode_retrieve_answer").execute({ query: "", top_k: 100 }), + /query is required|top_k must be/, + ); + assert.equal(h.calls.length, 0); +}); + +test("HTTP, malformed JSON, and oversized responses fail without leaking bodies or retries", async (t) => { + const h = harness(t, [ + { status: 429, headers: { "retry-after": "15" }, bodyText: "secret backend detail" }, + { status: 200, headers: {}, bodyText: "not-json" }, + { status: 200, headers: {}, bodyText: `{"data":"${"x".repeat(256 * 1024)}"}` }, + ]); + await h.plugin.onLoad(); + const tool = h.registered.get("memcode_test_connection"); + await assert.rejects(tool.execute({}), (error) => { + assert.match(error.message, /HTTP 429.*Retry after 15/); + assert.doesNotMatch(error.message, /secret backend detail/); + return true; + }); + await assert.rejects(tool.execute({}), /invalid JSON/); + await assert.rejects(tool.execute({}), /256 KiB/); + assert.equal(h.calls.length, 3); +});