diff --git a/packages/docs/src/content/docs/extend/memory-plugin.md b/packages/docs/src/content/docs/extend/memory-plugin.md index 5b67ce5ea3..368988d0e0 100644 --- a/packages/docs/src/content/docs/extend/memory-plugin.md +++ b/packages/docs/src/content/docs/extend/memory-plugin.md @@ -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 @@ -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 diff --git a/packages/junior-dashboard/e2e/user-pages.spec.ts b/packages/junior-dashboard/e2e/user-pages.spec.ts index f7f8d457f9..a15ec5bf83 100644 --- a/packages/junior-dashboard/e2e/user-pages.spec.ts +++ b/packages/junior-dashboard/e2e/user-pages.spec.ts @@ -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); +}); diff --git a/packages/junior-dashboard/src/client/http.ts b/packages/junior-dashboard/src/client/http.ts index 18ec435655..b184abd783 100644 --- a/packages/junior-dashboard/src/client/http.ts +++ b/packages/junior-dashboard/src/client/http.ts @@ -76,6 +76,19 @@ export async function deleteDashboardResource(path: string): Promise { 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 { + 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( schema: ZodType, diff --git a/packages/junior-dashboard/src/client/pages/memory/MemoryPage.tsx b/packages/junior-dashboard/src/client/pages/memory/MemoryPage.tsx index 5136930b1c..b9965f93df 100644 --- a/packages/junior-dashboard/src/client/pages/memory/MemoryPage.tsx +++ b/packages/junior-dashboard/src/client/pages/memory/MemoryPage.tsx @@ -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 ( @@ -791,16 +797,31 @@ function MemoryDetails(props: { ))} ) : null} - {forgetAction ? ( - + {publishAction || forgetAction ? ( +
+ {publishAction ? ( + + ) : null} + {forgetAction ? ( + + ) : null} +
) : isPublic ? (