Skip to content
Draft
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
33 changes: 22 additions & 11 deletions packages/docs/src/content/docs/extend/memory-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,26 +78,34 @@ For non-Neon managed Postgres (Railway, Supabase, AWS RDS, or self-hosted), set

## Manage personal memories

Signed-in users can search, page through, and forget their personal memories
Signed-in users can search, page through, publish, and forget their personal memories
from the top-level **Memories** dashboard page. The page shows viewer-scoped
memory totals, embedding coverage, and history on **Overview**. The separate
**Memories** view provides search and collections for preferences,
automatically learned memories, and explicitly saved memories. Each record
explains whether Junior learned it automatically or saved it because the user
asked. Overview groups the viewer's active memories by type and how they were
added. Forgetting archives the memory so Junior no longer recalls it.
added. Personal memories tied to a provider workspace can be made public to
that workspace. Stored content stays canonical; publishing snapshots a display
label as separate subject metadata for the dashboard and recall prompt. Owners
can forget their published personal memories; shared workspace knowledge
remains view-only. Forgetting archives the memory so Junior no longer recalls
it.

The plugin also exposes authenticated REST resources:

| Method | Path | Purpose |
| -------- | ---------------------------------- | --------------------------------------------- |
| `GET` | `/api/plugins/memory/dashboard` | Read viewer-scoped memory totals and timeline |
| `GET` | `/api/plugins/memory/memories` | List memories with `q`, `cursor`, and `limit` |
| `GET` | `/api/plugins/memory/memories/:id` | Read one personal memory |
| `DELETE` | `/api/plugins/memory/memories/:id` | Forget one personal memory |
| Method | Path | Purpose |
| -------- | ------------------------------------------ | ------------------------------------------------ |
| `GET` | `/api/plugins/memory/dashboard` | Read viewer-scoped memory totals and timeline |
| `GET` | `/api/plugins/memory/memories` | List memories with `q`, `cursor`, and `limit` |
| `GET` | `/api/plugins/memory/memories/:id` | Read one personal memory |
| `POST` | `/api/plugins/memory/memories/:id/publish` | Make one workspace-backed personal memory public |
| `DELETE` | `/api/plugins/memory/memories/:id` | Forget one personal memory |

Personal API tokens can use the read endpoints. Deletion requires an
authenticated dashboard browser session.
Personal API tokens can use the read endpoints. Publishing and deletion require
an authenticated dashboard browser session. User-subject memories include a
structured `subject` projection with the published display label when
available; stored `content` remains canonical and subject-less.

## Run migrations

Expand Down Expand Up @@ -131,7 +139,10 @@ What do you remember about my preferences?

Junior should recall the preference without prompting.

Public Slack channel memories are workspace-visible. A durable fact remembered in a public channel or public-channel thread can be recalled from another public channel in the same Slack workspace. Private Slack and local conversation memories remain scoped to their original conversation.
Workspace-public memories can be recalled from public conversations in the same
provider workspace. Private conversation memories remain scoped to their
original conversation, and local identities without a workspace cannot publish
personal memories.

## Failure modes

Expand Down
70 changes: 70 additions & 0 deletions packages/junior-dashboard/e2e/user-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,73 @@ test("searches, paginates, and forgets plugin page records", async ({
await expect(page.getByText("No memories yet.")).toBeVisible();
expect(browserErrors).toEqual([]);
});

test("publishes a private memory from the memory details", async ({ page }) => {
let published = false;
let publishRequests = 0;
await page.route("**/api/user-pages/memory/memories*", async (route) => {
await route.fulfill({
json: {
type: "list",
emptyText: "No memories yet.",
records: [
{
actions: published
? [
{
confirmation: "Forget this memory?",
href: "/api/plugins/memory/memories/memory-ooo",
label: "Forget",
method: "DELETE",
tone: "danger",
},
]
: [
{
confirmation:
"Make this memory public to the workspace where it was created?",
href: "/api/plugins/memory/memories/memory-ooo/publish",
label: "Make Public",
method: "POST",
tone: "neutral",
},
],
id: "memory-ooo",
metadata: [
{ label: "Type", value: "Knowledge" },
{ label: "Visibility", value: published ? "Public" : "Private" },
{ label: "Remembered", value: "Aug 4, 2026, 10:00 AM" },
],
title: published
? "David Cramer — Out of office August 10–14, 2026."
: "Out of office August 10–14, 2026.",
},
],
searchPlaceholder: "Search memories",
},
});
});
await page.route(
"**/api/plugins/memory/memories/memory-ooo/publish",
async (route) => {
publishRequests += 1;
expect(route.request().method()).toBe("POST");
published = true;
await route.fulfill({ status: 204 });
},
);

await page.goto(`${server.baseURL}/plugins/memory/memories/library`);
await page
.getByRole("button", { name: /^Out of office August 10–14, 2026/ })
.click();
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Make public" }).click();

await expect(
page.getByRole("button", {
name: /^David Cramer — Out of office August 10–14, 2026/,
}),
).toBeVisible();
expect(publishRequests).toBe(1);
});
13 changes: 13 additions & 0 deletions packages/junior-dashboard/src/client/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ export async function deleteDashboardResource(path: string): Promise<void> {
if (!response.ok) throw new DashboardApiError(path, response.status);
}

/** Run one authenticated dashboard resource action without a request body. */
export async function mutateDashboardResource(
path: string,
method: "DELETE" | "POST",
): Promise<void> {
const response = await fetch(path, {
credentials: "same-origin",
method,
});
if (response.status === 401) restartDashboardSignIn();
if (!response.ok) throw new DashboardApiError(path, response.status);
}

/** Fetch one authenticated dashboard JSON resource and validate its response. */
export async function fetchDashboardJson<T>(
schema: ZodType<T>,
Expand Down
53 changes: 37 additions & 16 deletions packages/junior-dashboard/src/client/pages/memory/MemoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -666,25 +666,31 @@ function MemoryDetails(props: {
const source = metadataValue(props.record, "Source");
const visibility = metadataValue(props.record, "Visibility");
const isPublic = visibility === "Public";
const forgetAction = props.record.actions?.find(
(recordAction) => recordAction.tone === "danger",
);
const ownedByViewer = !isPublic || Boolean(forgetAction);
const story =
learned === "Automatic"
? `Junior learned this from a ${source} conversation on ${shortDate(remembered)}.`
: learned === "Explicit"
? isPublic
? `Someone asked Junior to remember this on ${shortDate(remembered)}.`
: `You asked Junior to remember this on ${shortDate(remembered)}.`
? ownedByViewer
? `You asked Junior to remember this on ${shortDate(remembered)}.`
: `Someone asked Junior to remember this on ${shortDate(remembered)}.`
: `Junior recorded this on ${shortDate(remembered)}.`;
const scopeCopy = isPublic
? `It is stored as workspace ${kind.toLowerCase()} for future channels.`
: `It is stored as a ${kind.toLowerCase()} for future conversations.`;
: kind === "Knowledge"
? "It is stored as personal knowledge for future conversations."
: `It is stored as a ${kind.toLowerCase()} for future conversations.`;
const hiddenMetadata = props.inline
? ["Type", "Learned", "Source", "Memory ID"]
: ["Learned", "Source", "Memory ID"];
const visibleMetadata = (props.record.metadata ?? []).filter(
(item) => !hiddenMetadata.includes(item.label),
);
const forgetAction = props.record.actions?.find(
(recordAction) => recordAction.tone === "danger",
const publishAction = props.record.actions?.find(
(recordAction) => recordAction.label === "Make Public",
);

return (
Expand Down Expand Up @@ -791,16 +797,31 @@ function MemoryDetails(props: {
))}
</dl>
) : null}
{forgetAction ? (
<button
className="mt-4 inline-flex cursor-pointer items-center gap-2 rounded border border-rose-300/15 bg-rose-300/[0.035] px-3 py-2 font-mono text-[0.62rem] uppercase tracking-[0.08em] text-rose-200/75 transition-colors hover:border-rose-300/30 hover:bg-rose-300/[0.07] hover:text-rose-100"
disabled={props.action.isPending}
onClick={() => props.onAction(forgetAction)}
type="button"
>
<Trash2 aria-hidden="true" size={13} />
Forget this memory
</button>
{publishAction || forgetAction ? (
<div className="mt-4 flex flex-wrap gap-2">
{publishAction ? (
<button
className="inline-flex cursor-pointer items-center gap-2 rounded border border-cyan-300/15 bg-cyan-300/[0.035] px-3 py-2 font-mono text-[0.62rem] uppercase tracking-[0.08em] text-cyan-100/75 transition-colors hover:border-cyan-300/30 hover:bg-cyan-300/[0.07] hover:text-cyan-50"
disabled={props.action.isPending}
onClick={() => props.onAction(publishAction)}
type="button"
>
<Globe2 aria-hidden="true" size={13} />
Make public
</button>
) : null}
{forgetAction ? (
<button
className="inline-flex cursor-pointer items-center gap-2 rounded border border-rose-300/15 bg-rose-300/[0.035] px-3 py-2 font-mono text-[0.62rem] uppercase tracking-[0.08em] text-rose-200/75 transition-colors hover:border-rose-300/30 hover:bg-rose-300/[0.07] hover:text-rose-100"
disabled={props.action.isPending}
onClick={() => props.onAction(forgetAction)}
type="button"
>
<Trash2 aria-hidden="true" size={13} />
Forget this memory
</button>
) : null}
</div>
) : isPublic ? (
<div className="mt-4 inline-flex items-center gap-2 rounded border border-white/[0.08] px-3 py-2 font-mono text-[0.62rem] uppercase tracking-[0.08em] text-dashboard-text-muted">
<Globe2 aria-hidden="true" size={13} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type PluginUserPageLink,
} from "@sentry/junior-plugin-api";

import { deleteDashboardResource, fetchDashboardJson } from "../../http";
import { fetchDashboardJson, mutateDashboardResource } from "../../http";

export type PluginUserPageRecord = PluginUserPageContent["records"][number];
export type PluginUserPageRecordAction = NonNullable<
Expand Down Expand Up @@ -83,7 +83,7 @@ export function usePluginUserPageData(page: PluginUserPageLink) {
);
const action = useMutation({
mutationFn: (recordAction: PluginUserPageRecordAction) =>
deleteDashboardResource(recordAction.href),
mutateDashboardResource(recordAction.href, recordAction.method),
onMutate: () => ({ pluginName: page.pluginName }),
onSuccess: async (_result, _recordAction, context) => {
await queryClient.resetQueries({
Expand Down
45 changes: 45 additions & 0 deletions packages/junior-evals/evals/memory/personal.eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,51 @@ describeEval("Personal Memory", slackEvals, (it) => {
});
});

const outOfOfficeThread = {
id: "thread-memory-out-of-office",
channel_id: "CMEMORYOUTOFOFFICE",
thread_ts: "17000000.000003",
};

it("when explicitly asked to remember expiring availability, store actor knowledge", async ({
run,
}) => {
await clearMemories();
const userText =
"Please remember that I will be out of office from August 10 through August 14, 2026 for a family trip. Expire this memory at 2026-08-15T00:00:00-07:00.";
const result = await run({
overrides: memoryPluginOverrides,
initialEvents: [mention(userText, { thread: outOfOfficeThread })],
criteria: rubric({
pass: [
"The assistant stores the actor's out-of-office dates and family-trip reason as an expiring personal memory.",
"The assistant does not expose hidden scope, actor, Slack, or subject identifiers.",
],
fail: [
"Do not reject the memory merely because it is temporary; it has an exact expiration.",
"Do not store the fact as shared conversation knowledge.",
],
}),
});

const rows = await readActiveMemories(outOfOfficeThread);
expect(rows).toEqual([
expect.objectContaining({
expiresAtMs: Date.parse("2026-08-15T00:00:00-07:00"),
kind: "knowledge",
scope: "personal",
subjectType: "user",
}),
]);
await expectActorMemorySemantics({
assistantText: visibleAssistantText(result),
expectedMeaning:
"The actor will be out of office from August 10 through August 14, 2026 for a family trip.",
storedMemories: rows,
userText,
});
});

const explicitDuplicateThread = {
id: "thread-memory-explicit-duplicate",
channel_id: "CMEMORYEXPLICITDUPLICATE",
Expand Down
1 change: 1 addition & 0 deletions packages/junior-memory/migrations/0007_low_giant_girl.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "junior_memory_memories" ADD COLUMN "subject_label" text;
Loading
Loading