Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/memory/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ Each agent runs as an isolated Docker container with standardized interfaces for
- `whatsapp.py` - WhatsApp via Twilio (webhook receiver, binding CRUD + test) (WHATSAPP-001)
- `webhooks.py` - Public webhook trigger endpoint + JWT-auth webhook management (WEBHOOK-001, #291)
- `messages.py` - Proactive agent-to-user messaging (#321)
- `public_memory.py` - Per-user memory write endpoint for channel sessions (MEM-001, #888)

*Subscriptions & Skills:*
- `subscriptions.py` - Subscription management (SUB-002)
Expand Down Expand Up @@ -305,7 +306,7 @@ Each agent runs as an isolated Docker container with standardized interfaces for
- Tools access auth context via `context.session` parameter
- Agent-to-agent collaboration uses agent-scoped keys for access control

**Tools** across 16 tool modules (`src/tools/`):
**Tools** across 17 tool modules (`src/tools/`):

| Module | Tools | Description |
|--------|-------|-------------|
Expand All @@ -325,6 +326,7 @@ Each agent runs as an isolated Docker container with standardized interfaces for
| `channels.ts` (2) | `list_channel_groups`, `send_group_message` | Channel group discovery and proactive group messaging (#349) |
| `messages.ts` (1) | `send_message` | Proactive user messaging by verified email (#321) |
| `files.ts` (1) | `share_file` | Outbound file sharing — publish file from `/home/developer/public/` and return download URL (FILES-001) |
| `memory.ts` (1) | `write_user_memory` | Write per-user memory blob in isolated store; resolves user email server-side from execution_id (MEM-001, #888) |

### Vector Log Aggregator (`config/vector.yaml`)

Expand Down Expand Up @@ -516,6 +518,7 @@ picks up on its next poll. (#389 S1a)
| POST | `/api/agents/{name}/shared-files` | Mint a download URL for a file in the publish dir (owner/admin or agent-scoped key; used by `share_file` MCP tool) |
| GET | `/api/agents/{name}/shared-files` | List active (non-revoked, non-expired) shared files with download counts |
| DELETE | `/api/agents/{name}/shared-files/{file_id}` | Revoke a shared file (owner-only; idempotent) |
| POST | `/api/agents/{name}/user-memory` | Write per-user memory blob; resolves user email from execution_id server-side (MEM-001, #888) |

**Note**: Route ordering is critical. `/context-stats` and `/autonomy-status` must be defined BEFORE `/{name}` catch-all route to avoid 404 errors.

Expand Down
3 changes: 2 additions & 1 deletion docs/memory/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -780,8 +780,9 @@ Trinity is autonomous agent orchestration and infrastructure — sovereign infra
- **Status**: ✅ Implemented (2026-03-19)
- **Requirement ID**: MEM-001
- **GitHub Issue**: #147
- **Description**: Email-verified public chat sessions maintain persistent per-user memory (text blob) scoped to `(agent_name, user_email)`, injected into every agent call and updated via background summarization every 5 messages.
- **Description**: Email-verified public chat sessions maintain persistent per-user memory (text blob) scoped to `(agent_name, user_email)`, injected into every agent call. Memory is updated via background summarization every 5 messages (auto) or explicitly via the `write_user_memory` MCP tool (agent-initiated, #888). The tool resolves the user email server-side from the execution record — agents never handle email addresses directly.
- **Database Tables**: `public_user_memory`
- **API**: `POST /api/agents/{name}/user-memory` (agent-scoped key + execution_id; user-facing triggers only)
- **Flow**: `docs/memory/feature-flows/public-agent-links.md#per-user-persistent-memory-mem-001`

### 15.1a-3 Agent Website Proxy (SITE-001)
Expand Down
2 changes: 2 additions & 0 deletions src/backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
from routers.users import router as users_router
from routers.debug import router as debug_router # #306 soak instrumentation
from routers.messages import router as messages_router # Proactive Messaging (#321)
from routers.public_memory import router as public_memory_router # MEM-001 write path (#888)
from routers.webhooks import router as webhooks_router # Webhook triggers (WEBHOOK-001, #291)
from routers.ws_tickets import router as ws_tickets_router # /ws ticket auth (#550)

Expand Down Expand Up @@ -826,6 +827,7 @@ async def add_security_headers(request: Request, call_next):
app.include_router(system_views_router) # System Views (ORG-001 Phase 2)
app.include_router(notifications_router) # Agent Notifications (NOTIF-001)
app.include_router(messages_router) # Proactive Messaging (#321)
app.include_router(public_memory_router) # MEM-001 write path (#888)
app.include_router(subscriptions_router) # Subscription Management (SUB-001)
app.include_router(monitoring_router) # Agent Monitoring (MON-001)
app.include_router(slack_public_router) # Slack Integration Public (SLACK-001)
Expand Down
93 changes: 93 additions & 0 deletions src/backend/routers/public_memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""
Public User Memory Router (MEM-001 write path).

Provides a write endpoint agents can call (via the write_user_memory MCP tool)
to persist facts about the user they are currently serving.

Security model:
- The caller never supplies a user email.
- The backend resolves the email from the execution record identified by
execution_id, after verifying the execution belongs to the calling agent
and was triggered by a user-facing channel (public/slack/telegram/whatsapp).
- This prevents an agent from writing memory for an arbitrary user.
"""

import logging
import re

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field

from database import db
from dependencies import get_current_user
from db_models import User

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/agents", tags=["user-memory"])

# Channels where a human user identity (verified email) is present.
_USER_FACING_TRIGGERS = {"public", "slack", "telegram", "whatsapp"}

# Lightweight email sanity check — not RFC-exhaustive, just catches obvious garbage.
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


class WriteUserMemoryRequest(BaseModel):
execution_id: str = Field(..., min_length=1, max_length=200)
memory_text: str = Field(..., max_length=8000)


@router.post("/{agent_name}/user-memory")
async def write_user_memory(
agent_name: str,
body: WriteUserMemoryRequest,
current_user: User = Depends(get_current_user),
):
"""
Write the per-user memory blob for the user currently being served.

The caller supplies:
- agent_name (path): the agent whose memory store to write to.
- execution_id (body): the current execution, used to resolve the user email server-side.
- memory_text (body): the complete updated memory blob (replaces previous content).

The user email is never accepted from the caller — it is looked up from the
execution record to prevent cross-user memory poisoning.
"""
if not db.can_user_access_agent(current_user.username, agent_name):
raise HTTPException(status_code=403, detail="Not authorized")

execution = db.get_execution(body.execution_id)
if not execution:
raise HTTPException(status_code=404, detail="Execution not found")

if execution.agent_name != agent_name:
raise HTTPException(status_code=403, detail="Execution does not belong to this agent")

triggered_by = (execution.triggered_by or "").lower()
if triggered_by not in _USER_FACING_TRIGGERS:
raise HTTPException(
status_code=422,
detail=(
f"write_user_memory is only available during user-facing sessions "
f"(public, slack, telegram, whatsapp). This execution was triggered by '{triggered_by}'."
),
)

user_email = execution.source_user_email
if not user_email or not _EMAIL_RE.match(user_email):
raise HTTPException(
status_code=422,
detail="No verified user email associated with this execution.",
)

# Upsert: create the row if it doesn't exist, then update the blob.
db.get_or_create_public_user_memory(agent_name, user_email)
db.update_public_user_memory(agent_name, user_email, body.memory_text)

logger.info(
f"[UserMemory] Updated memory for {user_email} on {agent_name} "
f"({len(body.memory_text)} chars, execution={body.execution_id})"
)
return {"success": True, "agent_name": agent_name, "user_email": user_email}
27 changes: 26 additions & 1 deletion src/backend/services/platform_prompt_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,28 @@
echo "sudo apt-get install -y ffmpeg" >> ~/.trinity/setup.sh
```

This script runs automatically on container start. Always update it when installing system-level packages."""
This script runs automatically on container start. Always update it when installing system-level packages.

### Remembering Things About Users (Public & Channel Sessions)

When serving users through a public link, WhatsApp, Telegram, or Slack session, the user's memory is **isolated per person** — what you know about one user is never shown to another.

**Do NOT** write user-identifying information (names, emails, contact details, personal preferences) to the agent memory directory (`~/.claude/projects/memory/`). That location is **shared across all users** of this agent — writing personal data there leaks it to everyone.

**Instead**, use the `mcp__trinity__write_user_memory` tool to persist facts about this specific user:

```
mcp__trinity__write_user_memory(
execution_id="<your execution_id from Execution Context>",
memory_text="User's name is Alice. Prefers concise answers. Works in PST timezone."
)
```

The `execution_id` is in the **Execution Context** block below. The platform stores the memory text in an isolated, per-user store and injects it back at the start of every future session with this user.

- Write the complete updated memory blob each time (read → update → write).
- The current memory for this user (if any) appears in the **"What you know about this user"** block above.
- Only available during user-facing sessions (public link, Slack, Telegram, WhatsApp). The tool returns an error if called from a scheduled task or agent-to-agent call."""


def format_user_memory_block(memory_text: str) -> str:
Expand Down Expand Up @@ -202,6 +223,7 @@ class ExecutionContext:
collaborators: Optional[List[str]] = None
platform_url: Optional[str] = None
timestamp: Optional[str] = None
execution_id: Optional[str] = None # MEM-001: for write_user_memory tool

@staticmethod
def derive_mode(triggered_by: Optional[str]) -> str:
Expand Down Expand Up @@ -321,6 +343,9 @@ def build_execution_context(ctx: ExecutionContext) -> str:
if agent:
lines.append(f"- **Agent**: {agent}")

if ctx.execution_id:
lines.append(f"- **Execution ID**: {ctx.execution_id}")

collaborators = _render_collaborators(ctx)
if collaborators:
lines.append(f"- **Collaborators**: {collaborators}")
Expand Down
1 change: 1 addition & 0 deletions src/backend/services/task_execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ async def execute_task(
schedule_name=(schedule_context or {}).get("name"),
schedule_cron=(schedule_context or {}).get("cron"),
schedule_next_run=(schedule_context or {}).get("next_run"),
execution_id=execution_id,
)
effective_system_prompt = compose_system_prompt(
execution_context=exec_ctx,
Expand Down
27 changes: 27 additions & 0 deletions src/mcp-server/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,33 @@ export class TrinityClient {
);
}

// ============================================================================
// Per-User Memory Write (MEM-001, #888)
// ============================================================================

/**
* Write the per-user memory blob for the user currently being served.
* The user email is resolved server-side from the execution record —
* the caller only supplies the execution_id and the memory text.
*/
async writeUserMemory(
agentName: string,
data: {
execution_id: string;
memory_text: string;
}
): Promise<{
success: boolean;
agent_name: string;
user_email: string;
}> {
return this.request(
"POST",
`/api/agents/${encodeURIComponent(agentName)}/user-memory`,
data
);
}

// ============================================================================
// Agent Event Subscriptions (EVT-001)
// ============================================================================
Expand Down
2 changes: 2 additions & 0 deletions src/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { createEventTools } from "./tools/events.js";
import { createChannelTools } from "./tools/channels.js";
import { createMessageTools } from "./tools/messages.js";
import { createFileTools } from "./tools/files.js";
import { createMemoryTools } from "./tools/memory.js";
import { withAudit } from "./audit.js";
import type { McpAuthContext } from "./types.js";

Expand Down Expand Up @@ -213,6 +214,7 @@ export async function createServer(config: ServerConfig = {}) {
createEventTools(client, requireApiKey),
createChannelTools(client, requireApiKey),
createMessageTools(client, requireApiKey),
createMemoryTools(client, requireApiKey), // MEM-001 write path (#888)
];
for (const group of toolGroups) {
addAllTools(group);
Expand Down
1 change: 1 addition & 0 deletions src/mcp-server/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export { createSkillsTools } from "./skills.js";
export { createSubscriptionTools } from "./subscriptions.js";
export { createNeverminedTools } from "./nevermined.js";
export { createEventTools } from "./events.js";
export { createMemoryTools } from "./memory.js";
121 changes: 121 additions & 0 deletions src/mcp-server/src/tools/memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Per-User Memory Tools (MEM-001, #888)
*
* Provides write_user_memory — the safe alternative to writing user PII to the
* shared agent filesystem. The user email is resolved server-side from the
* execution record so the agent cannot write memory for an arbitrary user.
*/

import { z } from "zod";
import { TrinityClient } from "../client.js";
import type { McpAuthContext } from "../types.js";

export function createMemoryTools(client: TrinityClient, requireApiKey: boolean) {
const getClient = (authContext?: McpAuthContext): TrinityClient => {
if (requireApiKey) {
if (!authContext?.mcpApiKey) {
throw new Error("MCP API key authentication required but no API key found in request context");
}
const userClient = new TrinityClient(client.getBaseUrl());
userClient.setToken(authContext.mcpApiKey);
return userClient;
}
return client;
};

return {
// ========================================================================
// write_user_memory — persist facts about the current user
// ========================================================================
writeUserMemory: {
name: "write_user_memory",
description:
"Persist facts about the user you are currently serving in an isolated, per-user memory store. " +
"Use this instead of writing to ~/.claude/projects/memory/ — that path is shared across ALL users " +
"of this agent and would leak one user's data to everyone else.\n\n" +
"**When to use**: The user explicitly asks you to remember something, or you learn a fact " +
"(name, preference, timezone, context) that would be useful in future sessions.\n\n" +
"**How it works**: Supply your `execution_id` (from the Execution Context block in your system prompt) " +
"and the complete updated memory blob. The platform resolves the user's email from the execution " +
"record — you never touch email addresses directly.\n\n" +
"**Write the complete blob**: Read the current memory from the 'What you know about this user' " +
"block (if present), incorporate the new fact, and write everything back.\n\n" +
"**Only works in user-facing sessions** (public link, Slack, Telegram, WhatsApp). " +
"Returns an error if called from a scheduled task or agent-to-agent execution.",
parameters: z.object({
execution_id: z
.string()
.min(1)
.describe(
"Your current execution_id — shown in the 'Execution Context' block of your system prompt " +
"as '- **Execution ID**: <id>'. Required so the platform can resolve the user's email."
),
memory_text: z
.string()
.max(8000)
.describe(
"The complete updated memory blob for this user. Write factual, concise notes " +
"(name, preferences, context, timezone, etc.). This replaces the previous memory entirely, " +
"so include anything from the existing memory you want to keep."
),
agent_name: z
.string()
.optional()
.describe(
"Agent name override. Defaults to the agent whose MCP key is making this call. " +
"Omit in normal use."
),
}),
execute: async (
{
execution_id,
memory_text,
agent_name,
}: {
execution_id: string;
memory_text: string;
agent_name?: string;
},
context: any
) => {
const authContext = requireApiKey ? context?.session : undefined;
const apiClient = getClient(authContext);

// Resolve agent name: prefer explicit param, fall back to key's agent_name, then error.
const resolvedAgent =
agent_name ||
(authContext?.scope === "agent" ? authContext.agentName : undefined);

if (!resolvedAgent) {
return JSON.stringify(
{
success: false,
error:
"Cannot determine agent name. Pass agent_name explicitly or call this tool " +
"using an agent-scoped MCP key.",
},
null,
2
);
}

console.log(
`[write_user_memory] ${resolvedAgent} execution=${execution_id} ` +
`memory_len=${memory_text.length}`
);

try {
const result = await apiClient.writeUserMemory(resolvedAgent, {
execution_id,
memory_text,
});
return JSON.stringify(result, null, 2);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[write_user_memory] Error: ${errorMessage}`);
return JSON.stringify({ success: false, error: errorMessage }, null, 2);
}
},
},
};
}
Loading