From 9878f681fadce3833f25bd1b368bacdddebe843e Mon Sep 17 00:00:00 2001 From: Amir Moradi Date: Sat, 9 May 2026 23:39:46 +0200 Subject: [PATCH 1/3] feat(paperclip): add user-scoped memory isolation for GDPR compliance - Add userId parameter to BankContext for per-user memory isolation - Extend bankGranularity to support 'user' option for isolating memories by user - Add resolveUserIdFromActiveIssue() to extract user email from active issue - Add 'issues.read' capability to permissions for accessing active issue data - Update all bank ID derivations to include user ID when granularity includes 'user' - Update manifest description to mention GDPR compliance when user option is enabled This allows agents to maintain user-scoped memory in multi-user scenarios, ensuring that personal/scoped conversations don't leak between users. --- hindsight-integrations/paperclip/src/bank.ts | 14 +++++--- .../paperclip/src/manifest.ts | 5 +-- .../paperclip/src/worker.ts | 34 ++++++++++++++++--- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/hindsight-integrations/paperclip/src/bank.ts b/hindsight-integrations/paperclip/src/bank.ts index 99ebd40b9..8ba9344ef 100644 --- a/hindsight-integrations/paperclip/src/bank.ts +++ b/hindsight-integrations/paperclip/src/bank.ts @@ -3,18 +3,20 @@ * * Default format: "paperclip::{companyId}::{agentId}" * - * bankGranularity: ['company'] → "paperclip::{companyId}" - * bankGranularity: ['agent'] → "paperclip::{agentId}" - * bankGranularity: ['company','agent'] → "paperclip::{companyId}::{agentId}" + * bankGranularity: ['company'] → "paperclip::{companyId}" + * bankGranularity: ['agent'] → "paperclip::{agentId}" + * bankGranularity: ['company','agent'] → "paperclip::{companyId}::{agentId}" + * bankGranularity: ['company','agent','user'] → "paperclip::{companyId}::{agentId}::user::{userId}" */ export interface BankContext { companyId: string; agentId: string; + userId?: string; } export interface BankConfig { - bankGranularity?: Array<"company" | "agent">; + bankGranularity?: Array<"company" | "agent" | "user">; } export function deriveBankId(context: BankContext, config: BankConfig): string { @@ -24,6 +26,10 @@ export function deriveBankId(context: BankContext, config: BankConfig): string { for (const field of granularity) { if (field === "company") parts.push(context.companyId); if (field === "agent") parts.push(context.agentId); + if (field === "user" && context.userId) { + parts.push("user"); + parts.push(context.userId); + } } return parts.join("::"); diff --git a/hindsight-integrations/paperclip/src/manifest.ts b/hindsight-integrations/paperclip/src/manifest.ts index 21e99f49d..ae92641f7 100644 --- a/hindsight-integrations/paperclip/src/manifest.ts +++ b/hindsight-integrations/paperclip/src/manifest.ts @@ -17,6 +17,7 @@ const manifest: PaperclipPluginManifestV1 = { "http.outbound", "secrets.read-ref", "agents.read", + "issues.read", ], entrypoints: { worker: "./dist/worker.js", @@ -42,8 +43,8 @@ const manifest: PaperclipPluginManifestV1 = { type: "array", title: "Bank Granularity", description: - "Controls memory isolation. Default ['company', 'agent'] gives each agent its own bank per company.", - items: { type: "string", enum: ["company", "agent"] }, + "Controls memory isolation. Default ['company', 'agent'] gives each agent its own bank per company. Add 'user' for per-user memory isolation (GDPR compliance).", + items: { type: "string", enum: ["company", "agent", "user"] }, default: ["company", "agent"], }, recallBudget: { diff --git a/hindsight-integrations/paperclip/src/worker.ts b/hindsight-integrations/paperclip/src/worker.ts index 85e41e0cd..ed7e7555e 100644 --- a/hindsight-integrations/paperclip/src/worker.ts +++ b/hindsight-integrations/paperclip/src/worker.ts @@ -20,7 +20,7 @@ import { deriveBankId } from "./bank.js"; interface PluginConfig { hindsightApiUrl: string; hindsightApiKeyRef?: string; - bankGranularity?: Array<"company" | "agent">; + bankGranularity?: Array<"company" | "agent" | "user">; recallBudget?: "low" | "mid" | "high"; autoRetain?: boolean; } @@ -54,6 +54,26 @@ async function resolveApiKey( return resolved ?? undefined; } +async function resolveUserIdFromActiveIssue( + ctx: { issues: { getActive(): Promise<{ originId?: string } | null> } }, + config: PluginConfig +): Promise { + // Only resolve user ID if user granularity is enabled + if (!config.bankGranularity?.includes("user")) return undefined; + + try { + const activeIssue = await ctx.issues.getActive(); + if (!activeIssue?.originId) return undefined; + + // Format: "slack::::" or similar + const parts = activeIssue.originId.split("::"); + const email = parts[parts.length - 1]; + return email && email.includes("@") ? email : undefined; + } catch (err) { + return undefined; + } +} + const plugin = definePlugin({ async setup(ctx) { ctx.logger.info("Hindsight memory plugin starting"); @@ -66,6 +86,7 @@ const plugin = definePlugin({ const config = await getConfig(ctx); const { agentId, runId, issueTitle, issueDescription } = payload; const companyId = event.companyId; + const userId = await resolveUserIdFromActiveIssue(ctx, config); const query = [issueTitle, issueDescription].filter(Boolean).join("\n"); if (!query.trim()) return; @@ -73,7 +94,7 @@ const plugin = definePlugin({ try { const apiKey = await resolveApiKey(ctx, config); const client = new HindsightClient(config.hindsightApiUrl, apiKey); - const bankId = deriveBankId({ companyId, agentId }, config); + const bankId = deriveBankId({ companyId, agentId, userId }, config); const response = await client.recall(bankId, query, config.recallBudget ?? "mid"); @@ -109,6 +130,7 @@ const plugin = definePlugin({ const { agentId, runId, output, result } = payload; const companyId = event.companyId; + const userId = await resolveUserIdFromActiveIssue(ctx, config); const content = output ?? result; if (!content?.trim()) return; @@ -116,7 +138,7 @@ const plugin = definePlugin({ try { const apiKey = await resolveApiKey(ctx, config); const client = new HindsightClient(config.hindsightApiUrl, apiKey); - const bankId = deriveBankId({ companyId, agentId }, config); + const bankId = deriveBankId({ companyId, agentId, userId }, config); await client.retain(bankId, content, runId, { agentId, companyId, runId }); ctx.logger.info("Retained run output to memory", { runId, bankId }); @@ -147,8 +169,9 @@ const plugin = definePlugin({ async (params: unknown, runCtx: ToolRunContext) => { const { query } = params as { query: string }; const config = await getConfig(ctx); + const userId = await resolveUserIdFromActiveIssue(ctx, config); const bankId = deriveBankId( - { companyId: runCtx.companyId, agentId: runCtx.agentId }, + { companyId: runCtx.companyId, agentId: runCtx.agentId, userId }, config ); @@ -198,8 +221,9 @@ const plugin = definePlugin({ async (params: unknown, runCtx: ToolRunContext) => { const { content } = params as { content: string }; const config = await getConfig(ctx); + const userId = await resolveUserIdFromActiveIssue(ctx, config); const bankId = deriveBankId( - { companyId: runCtx.companyId, agentId: runCtx.agentId }, + { companyId: runCtx.companyId, agentId: runCtx.agentId, userId }, config ); From 0b09ee1e95845e6f5250e06e9ce24109ecdcb2c0 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Tue, 12 May 2026 00:11:06 +0200 Subject: [PATCH 2/3] fix(paperclip): use ctx.issues.list() instead of non-existent getActive() method The getActive() method does not exist in the Paperclip plugin SDK. Instead, use ctx.issues.list() to retrieve the agent's issues, then extract the user email from the first (most active) issue's originId field. This fixes the TypeScript compilation errors reported in the PR review. --- .../paperclip/src/worker.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/hindsight-integrations/paperclip/src/worker.ts b/hindsight-integrations/paperclip/src/worker.ts index ed7e7555e..a291d4469 100644 --- a/hindsight-integrations/paperclip/src/worker.ts +++ b/hindsight-integrations/paperclip/src/worker.ts @@ -55,20 +55,29 @@ async function resolveApiKey( } async function resolveUserIdFromActiveIssue( - ctx: { issues: { getActive(): Promise<{ originId?: string } | null> } }, + ctx: { issues: { list(): Promise> } }, config: PluginConfig ): Promise { // Only resolve user ID if user granularity is enabled if (!config.bankGranularity?.includes("user")) return undefined; try { - const activeIssue = await ctx.issues.getActive(); + const issues = await ctx.issues.list(); + if (!issues || issues.length === 0) return undefined; + + // Get the first issue (most recently active) + const activeIssue = issues[0]; if (!activeIssue?.originId) return undefined; - // Format: "slack::::" or similar + // Format: "channel-key::user-email" — extract the email + // Search backwards for a part that looks like an email (contains "@") const parts = activeIssue.originId.split("::"); - const email = parts[parts.length - 1]; - return email && email.includes("@") ? email : undefined; + for (let i = parts.length - 1; i >= 0; i--) { + if (parts[i].includes("@")) { + return parts[i]; + } + } + return undefined; } catch (err) { return undefined; } From c993be3a3fc8ff2000040aed015974d2806a152d Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Tue, 12 May 2026 00:14:34 +0200 Subject: [PATCH 3/3] fix(paperclip): use correct ctx.issues.list() signature from plugin SDK The previous fix still had the wrong type signature. The actual SDK PluginIssuesClient.list() requires a {companyId, ...} input object. Changes: - Import PluginContext type from @paperclipai/plugin-sdk - resolveUserIdFromActiveIssue now takes (ctx: PluginContext, companyId, agentId, config) - Calls ctx.issues.list({ companyId, assigneeAgentId: agentId, status: 'in_progress', limit: 1 }) to find the agent's currently active issue - All 4 call sites updated to pass companyId and agentId from their respective contexts Verified: npm run typecheck passes with zero errors. --- .../paperclip/src/worker.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/hindsight-integrations/paperclip/src/worker.ts b/hindsight-integrations/paperclip/src/worker.ts index a291d4469..2b04fb357 100644 --- a/hindsight-integrations/paperclip/src/worker.ts +++ b/hindsight-integrations/paperclip/src/worker.ts @@ -13,7 +13,7 @@ */ import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; -import type { ToolRunContext } from "@paperclipai/plugin-sdk"; +import type { PluginContext, ToolRunContext } from "@paperclipai/plugin-sdk"; import { HindsightClient, formatMemories } from "./client.js"; import { deriveBankId } from "./bank.js"; @@ -55,23 +55,29 @@ async function resolveApiKey( } async function resolveUserIdFromActiveIssue( - ctx: { issues: { list(): Promise> } }, + ctx: PluginContext, + companyId: string, + agentId: string, config: PluginConfig ): Promise { // Only resolve user ID if user granularity is enabled if (!config.bankGranularity?.includes("user")) return undefined; try { - const issues = await ctx.issues.list(); + const issues = await ctx.issues.list({ + companyId, + assigneeAgentId: agentId, + status: "in_progress", + limit: 1, + }); if (!issues || issues.length === 0) return undefined; - // Get the first issue (most recently active) - const activeIssue = issues[0]; - if (!activeIssue?.originId) return undefined; + const originId = issues[0]?.originId; + if (!originId) return undefined; // Format: "channel-key::user-email" — extract the email // Search backwards for a part that looks like an email (contains "@") - const parts = activeIssue.originId.split("::"); + const parts = originId.split("::"); for (let i = parts.length - 1; i >= 0; i--) { if (parts[i].includes("@")) { return parts[i]; @@ -95,7 +101,7 @@ const plugin = definePlugin({ const config = await getConfig(ctx); const { agentId, runId, issueTitle, issueDescription } = payload; const companyId = event.companyId; - const userId = await resolveUserIdFromActiveIssue(ctx, config); + const userId = await resolveUserIdFromActiveIssue(ctx, companyId, agentId, config); const query = [issueTitle, issueDescription].filter(Boolean).join("\n"); if (!query.trim()) return; @@ -139,7 +145,7 @@ const plugin = definePlugin({ const { agentId, runId, output, result } = payload; const companyId = event.companyId; - const userId = await resolveUserIdFromActiveIssue(ctx, config); + const userId = await resolveUserIdFromActiveIssue(ctx, companyId, agentId, config); const content = output ?? result; if (!content?.trim()) return; @@ -178,7 +184,7 @@ const plugin = definePlugin({ async (params: unknown, runCtx: ToolRunContext) => { const { query } = params as { query: string }; const config = await getConfig(ctx); - const userId = await resolveUserIdFromActiveIssue(ctx, config); + const userId = await resolveUserIdFromActiveIssue(ctx, runCtx.companyId, runCtx.agentId, config); const bankId = deriveBankId( { companyId: runCtx.companyId, agentId: runCtx.agentId, userId }, config @@ -230,7 +236,7 @@ const plugin = definePlugin({ async (params: unknown, runCtx: ToolRunContext) => { const { content } = params as { content: string }; const config = await getConfig(ctx); - const userId = await resolveUserIdFromActiveIssue(ctx, config); + const userId = await resolveUserIdFromActiveIssue(ctx, runCtx.companyId, runCtx.agentId, config); const bankId = deriveBankId( { companyId: runCtx.companyId, agentId: runCtx.agentId, userId }, config