From fe25859bec4bcb55a236c6760d01fa5bb974e55b Mon Sep 17 00:00:00 2001 From: Vivek Gupta Date: Sun, 20 Sep 2026 23:11:17 +0530 Subject: [PATCH 1/2] feat: add Memcode memory plugin --- catalog.json | 47 +++++- packages/in.memcode.memory-0.1.0.piplug | Bin 0 -> 14955 bytes plugins/in.memcode.memory/README.md | 54 +++++++ plugins/in.memcode.memory/main.js | 196 ++++++++++++++++++++++++ plugins/in.memcode.memory/manifest.json | 91 +++++++++++ tests/memcode-memory.test.mjs | 123 +++++++++++++++ 6 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 packages/in.memcode.memory-0.1.0.piplug create mode 100644 plugins/in.memcode.memory/README.md create mode 100644 plugins/in.memcode.memory/main.js create mode 100644 plugins/in.memcode.memory/manifest.json create mode 100644 tests/memcode-memory.test.mjs 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 0000000000000000000000000000000000000000..8c5972e7364769802332e61ba8ce85b262b7677c GIT binary patch literal 14955 zcmd^G?{6H(c~+aYL9Zy#5BXYPycW`9aknI8J8;NQ0xdBPD^p@cra-|6cgwpu-imv> zm)$*5XYv3ise`1oW#v2uVqw@)rc?^S(2)JG;E2 zq?l?0)P{Jx-I;gZ-|sum3|B9Is=}O!C=b|D}zKPhRB~KqA%kl3cWz=oD++y%QcZiBE@L@E?Ti{W4@2KZAnY{ zA?DIs5Q*nS7!kEtEMF2)Ec`H)Td|k=Q7F7Ljs495|L!NkYqexQm2J_9qn=_)c=6Vt z$J|vaPd_czWIE_qD)`_9tfl>spU7~>kE4*L;JcTKB=zDnv81ihxKPBB-_Vze*YD3& zD$Qnd(@VM)=rD?*!KIapS1&C!AY$Y4(qAtOqe0w&5=rR|ukSau<&fr7D(jirL@0M4 zDQU}3uxi?cSUX-YkdWKrK_oIw{csC9UnBm+uAg=*O+!5eZ;e(IhO$MvX=;{@af6`% z$8oeHg*NJL>|>7DCBa}3FWF99*4H{&yHeQ~^o!UR*ZgD~A6LPlGQmFH)$4UUvwq^) zL|!m*MYjDx55F{DTv`{?JLjf};pRL^>bE`Ogscw%I{wxG-eJ24Ng54O=-+EY14aVk zBU>}^b|k49e%-_}pYgZ4wA$*`HMN|2HI3=8Lwxm%mfjnXo27mW`e<_` zAdEEQt?nS)hDWsJ&EmRXH1^>_4KGY~Wqk6wW-wOH1Qc6wGyvT;WF-nLege;JNs=^* zkt6mi6)X11+bPz;ZGwo!T*N3`H;m#QB3h+`dOwyO|0c*%`VgVlliI;9dVO!x5BwB9 z2~ETLUcD1UyI5_{;EN~t4BC^77di{w1u zfc$@YlVk$p;$k4ZaL{L^S^jGKuKha}Ukn1B^58(FkZv-|EK!zbut>9;ru}4YdRke_ z#RtW_>xWGdg$O zqw7kUoSeLX^iZ3bnW?wBUhK6nUvzvKuq7`IVsA5$>@cDek=!b4PzWPZR&;fPyc9+7 z36nCF%p7ymCIrClUk)U^jC_#21sV!bIAXsgl7cVP#R#_ z`w{Bza9(^qV+V>J1gYYRn1Ee%Pz6y7CTV#sBufOPRYtlV1t67=xLxm~Knz7ZfEs#G znCwdgY1l$usv#0nAJ!-TtcmszfeC^6TczU%G8w{ZD5WT@2}Y+fkUgZBVU7F?Z9P`XsjaMMO=d#5yLtjWrO+5 zl;nF`oP~##NnHS~ixf`5pbFThJwHGR5u38>?f6k#sno>^WeJoHTEz=GNK{}WZv_(3 z!6eMU8w3WFi)h`A;%%rp2wV{O%=-GOkZ~M=Rh9o#VAEJcM~c!=wLPhMjg(ej>`(~6 zTVwI;xlc|(`%zjEc_eK{ZKxD0qU~r!g8)oLA%!a`F7eq?=#ao1k|)H-cn3igbMy=^ zLy*5%yLu&~iJAo&VtE5;Lbhp^QRA;It*u_YvbNNCX?bON9dju0|GAi*Irpp?Vk{ur>(rQG%*OTk zN~JRhSu)7^`2kA8V&A_kht(;u2lrCMRTH^@b}@zps)#-cX&3FTTdHbee_uGxl#7AA z>eM_a_#IJwiU}x+0xlY(M2B)$EKw9xoiYs=mTbWaR4p8Necm>>4pSa!>P*}XWVn@f z#d$INiD#aFmiU<7_+0$C@cOh%4PsTX@Hjm9A?EmPYc+T~S~=BI&sRprb=TG!;>9RI z_TU^n@8(O)oUKyT;c(1ldgWQ-!i8}f%*Rh1Q(*J#9<4N@h|orCyUk4d`P5uV7Efh! zWYpx%mXxTMXZNzvBav0pM6aNAk}pzJj4A$)pcwKLrGkZp1#SjA+-$&<`QvbaMyK!u z?gpvZFw{Wu9CAGU%qZk!2F5eJOxr~nE3Ptg`k~(&^w_TSi?Zq?Ge4eLj*=^b-lmLQ zKPjv`m5ci+f$c4=nLj#D?v=A~Ji0P-a*o?X9t$~bLM~**bXTI0gM}5{5oazoIMEzw z8&jlB_I+bw#s2@2veIE+Mjh=KPr+XtbX?G$L#lZy)8wZiS1NVz=UO`zMV_wqmcV~e zckJ}Z>A9Dphn)nKT$yO4oRCh8$3&qNQD+WrqDkDqcB<;sgfnG4WU_a(qFx`Rvl?wu z8=<7pqp$KKmjD;qrlF=+^&=E@{;DUrXeZGfRI3{`W%27%V&S}H{4DUNkoAz4d7~|| z%BD9_*HGZ76i_j)i9PBPyHT6!E1(33gBA+Ssc9n@#NJ4Z@OE!xCvJc*D35qtDwmXU zRJF`DHw(hs^#F(UeK*^(RjH~9F^GdXfzZs${OIf+@il7Jc!1(f{jNEhUDBR00&M$~(W@eNX$jR4pWV}yD<3oECa}7! z&eX&+=O_eWDF>Y(t$D1P=X+Upe$!P`7pt}+XZK1<8o6ip6w@QsL{R!yvXG_L3E0Yd zmTYeJ3R*`=CT_?w=5gM76g$=9>Bq@A){up2M6ea0Es_B=0>9^{x;Mkl03G&_b_%pE zbg4r{KLopm4{<>`8anqxX53AYua9UhIvfwkik@HAro|gI9AJs7Dbk`}+B;cYBiDAr z3ACe2mwCd)OC>rkUW;+;4bcSfCvp{wHDz4(u{biu5lPqUON%A=5;d}#xJz4Qa>F8L z>M?n#W7xY;oHA#=a?({ULO=xXEuhRj{1e;-!$EZtZcsOF;mbN@p&+$lK%oFzF!ry2 zopi8Aq|GcD9i$DKYrG@ha#P!@EMT@~2HwT4fwraguF=)&5p2Xv5t^)@CBQ;@9%VgJ z1m0?~YXUnS>DDsn<@Y-#VB~WYCdeiX&0Bkzx$(yn@NI^x_BJ8J7v9u0MA#x0S z?Qja^nPYWkVbWvu1@w}jHAZ?%5Ht>3NP}Y$VWZ3JoJMV~hb1L-fwPrW#Fj;<6&0nQ z7SyGmVJs4drCgQjQFhULs{PTNn6-9GI@23{k|*(&yno z`ydmUs%myH#2e|*xo+N^;D&05rcw4q0L~Pm)Y!l&mB*PBZ}G4C9Mj(GGDLG-YY+(u zZIzxtfk@O`1;}tZ-;8sAJi|$gK2E_j!;R4G8N!gxJhcB2v8)}k)+AQZvlRHKgEMq2 zrBU40WV#hx80L^*RF|5sjvftoV1uF5In(T!{XHvzYyrvHBoLR{lB^RVKFi)ZPLahi zpjLc9YCJ}_=*NaC?;n^i`#mVyUqP?r-@Ln2E}sReAe50ko$0AB#~MVH?dHS_DhR69 zSW69gb4cwPsfBgVKa{cwy5LN#4mTAvo}GS;O?PVyz&|{%v2PJnNO&sRVVo@k^G;Gg zvw%nG94slNI)J%j>VekG<0L!nH^;Ng#NdAxhR%Z??hjafT=p<&3Q&g!Y#%0dlqmF@ zkr;id<>NSJZ=l*`%rF3sy)x%s+UT3itA8&tViec-aAfqpvyqWgTw837OQ*&i9$gt* zLA2=wMFnVhqXBR=ww0bHvO!tC(SBv<8}v&hS#n*h z!DFxpI)qu;z@d+xFrsCwuEP1hT%B4PwG#k`YQFVpdW}no914A!K(28mg$mdWfCbDZ;Na!G3=I({p1 zm9Z$?;!(1d86(u$_4tPCAzA7%)5vowi#%tVX|1-*Rq1T3gd;}bOMn>k(O8Z$pg|JV zeD@2iR2?gtJv&-Kg9CDVm)&TfbGRyPn5{-x-F47sbZ&o;ba{kJZIbDrkU0%bQRS0l zvyxf|ds5tGkuDBJaaS$HAu0f}BHe^=Lm)^~ZqMsit4y5s0)Q$Ni}`^qGsy!8T%}~I zFbH0l9*o8yu2HBCezmTiJYN8PQHBPAbkz+y-rwM0oeYNi1)80jXfXajO-HfN;w}R0 zkU+11fr{s}oB#cvPw)T9nKSh7kNKQ7^gBpFIIE39a$H9N2LLVT{t&htv*c6Q5)odC zGhMwBePXM_$;}~!((9GNMN0KfA6nDv8F$v5QBN&|gx8fB zfGd;SG6T6lCv0Mq8)!POmhC$336w6sFt=!)RgOfD>EXs(5N*-o%Q#h`E0-}27ymS4M2!vMXeoNi4#WXf6^D7oUb#u)j`N zd)_v>dvoW8dNWYQ4G_901<}=lnf_r4K&u{(G|MN>StYR)3J`-%xrKeHleDKUKJX@j z@0x&7zTXEll5Rs~VAGIrDWFCK%m6^wn&%^S`$j0P8xhiA-QrPK2)#gy?9V<=KBY3G zBlF+kVkdYJ1u7b{3|0F-@EK;||Chx$Kk7>2yxgr{ypox2jJfW=_v6D4zO1{QVx4}S5^(c8D~efPb4|L`}5ciugG_0F$;_S(H~{@~!Jw+=q|&f$;V z{^cuQIC}Hj_x|mBhhKl~==-nGFF*gr;a6YBn4_=U!7O3Yj^f~#uY56QqGYgxyRRO; z`L*MrBo5#B+WmjObNI&JKls+0_ix`h`qwXl%)#ATHp}-vc<0^^{_ftb+lSx#(*4_a z58rsJ9D2n0-R~UyR)~w{IW4_JxD@ zUp;*7t)p+ee(>{egTmqa{{cSig&+Lz?FZk#_266oqV#k4-gl3_`ab9!{p~-Iu*yvM z$)RM!em*d4WE9+nR-8hUBqZob4XwV52{g+M6m(Fse(d2O~oo1F(jos*U)~6_=@lsWC1|jZUWe4C$twn}Xc27 z@ghvG6gcL{2xC8lA9F`n21H8n>?IUscum?egBgBOG!*}&M(Zk=YR+>=(2kG zcYgO{XD0u>rujQg^_l(kW_eoq9(q2lfj|7z&2M>985GGUeRPT@SrEzU<=_7Udi@dn N|Ihe*@00ZHe*hki`K 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); +}); From 6dab161f63a510714fdec978a04a2a74cb9ef0c5 Mon Sep 17 00:00:00 2001 From: vivekgupta-memcode Date: Mon, 21 Sep 2026 13:04:45 +0530 Subject: [PATCH 2/2] docs: use server-attributed Memcode integration keys --- plugins/in.memcode.memory/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/in.memcode.memory/README.md b/plugins/in.memcode.memory/README.md index 6e25cb5..491f8af 100644 --- a/plugins/in.memcode.memory/README.md +++ b/plugins/in.memcode.memory/README.md @@ -6,14 +6,18 @@ user ID or integration attribution is accepted from agent arguments. ## Setup -Set the credential in the environment that starts PI-Desktop, then restart the -app: +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 ``` -The plugin never persists this value in PI-Desktop settings. Start with +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