diff --git a/packages/app/e2e/settings/settings-memory.spec.ts b/packages/app/e2e/settings/settings-memory.spec.ts
index 0a36ab0d5..c6d3a598d 100644
--- a/packages/app/e2e/settings/settings-memory.spec.ts
+++ b/packages/app/e2e/settings/settings-memory.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
-import { closeSettingsPanel, openSettings, withSession } from "../actions"
+import { openSettings, withSession } from "../actions"
test("@smoke memory settings exposes the raw MEMORY.md controls", async ({ page, project }) => {
await project.open()
@@ -39,75 +39,15 @@ test("@smoke memory settings exposes the raw MEMORY.md controls", async ({ page,
.toContain("e2e memory settings profile")
})
-test("idle session shows memory review and saves redacted entries", async ({ page, project }) => {
+test("idle session does not render memory review", async ({ page, project }) => {
await project.open()
await project.sdk.memory.reset()
await project.sdk.memory.disabled({ memoryDisabledInput: { disabled: false } })
- await withSession(project.sdk, "memory review e2e", async (session) => {
+ await withSession(project.sdk, "silent memory e2e", async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
- const review = page.locator('[data-component="session-memory-review"]')
- await expect(review).toBeVisible()
- await expect(review.getByText("Remember this session?")).toBeVisible()
-
- await review.locator('[data-action="session-memory-review-text"]').fill("Remember sk-test-secret from this session")
- await review.getByRole("button", { name: "Save memory" }).click()
-
- await expect(review).toBeHidden()
- await expect
- .poll(async () => {
- const state = await project.sdk.memory.get().then((response) => response.data as { content?: string })
- return state.content ?? ""
- })
- .toContain("[REDACTED]")
-
- const state = await project.sdk.memory.get().then((response) => response.data as { content?: string })
- expect(state.content ?? "").not.toContain("sk-test-secret")
- })
-})
-
-test("disabled memory hides the idle session review surface", async ({ page, project }) => {
- await project.open()
- await project.sdk.memory.reset()
- await project.sdk.memory.disabled({ memoryDisabledInput: { disabled: true } })
- try {
- await project.sdk.memory.acceptProposal({ memoryProposalInput: { text: "Should not be saved.", scope: "project" } })
- throw new Error("expected disabled memory proposal to be blocked")
- } catch (error) {
- expect((error as { error?: string }).error).toBe("memory_disabled")
- }
-
- try {
- await withSession(project.sdk, "disabled memory review e2e", async (session) => {
- project.trackSession(session.id)
- await project.gotoSession(session.id)
-
- await expect(page.locator('[data-component="session-memory-review"]')).toBeHidden()
- })
- } finally {
- await project.sdk.memory.disabled({ memoryDisabledInput: { disabled: false } })
- }
-})
-
-test("turning memory off hides an already visible session review surface", async ({ page, project }) => {
- await project.open()
- await project.sdk.memory.reset()
- await project.sdk.memory.disabled({ memoryDisabledInput: { disabled: false } })
-
- await withSession(project.sdk, "memory review toggle e2e", async (session) => {
- project.trackSession(session.id)
- await project.gotoSession(session.id)
-
- const review = page.locator('[data-component="session-memory-review"]')
- await expect(review).toBeVisible()
-
- const settings = await openSettings(page)
- await settings.getByRole("tab", { name: "Memory" }).click()
- await settings.locator('[data-slot="switch-control"]').click()
- await closeSettingsPanel(page, settings)
-
- await expect(review).toBeHidden()
+ await expect(page.locator('[data-component="session-memory-review"]')).toHaveCount(0)
})
})
diff --git a/packages/app/src/components/memory/session-memory-review.tsx b/packages/app/src/components/memory/session-memory-review.tsx
deleted file mode 100644
index ba8fbf9ca..000000000
--- a/packages/app/src/components/memory/session-memory-review.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-import { Button } from "@opencode-ai/ui/button"
-import { showToast } from "@opencode-ai/ui/toast"
-import { createEffect, createResource, createSignal, Show } from "solid-js"
-import { useLanguage } from "@/context/language"
-import { useSDK } from "@/context/sdk"
-import { memoryStateVersion } from "./memory-state-sync"
-
-type MemoryState = {
- disabled?: boolean
- status?: "ok" | "safe_mode"
-}
-
-type MemoryError = {
- error?: string
- reason?: string
-}
-
-const errorMessage = (error: unknown, fallback: string) => (error instanceof Error ? error.message : fallback)
-
-const memoryError = (value: unknown): MemoryError | undefined => {
- if (!value || typeof value !== "object") return undefined
- if ("error" in value || "reason" in value) return value as MemoryError
- if ("data" in value) return memoryError((value as { data?: unknown }).data)
- return undefined
-}
-
-export function SessionMemoryReview(props: { sessionID?: string; visible: boolean }) {
- const language = useLanguage()
- const sdk = useSDK()
- const [draft, setDraft] = createSignal("")
- const [dismissed, setDismissed] = createSignal(false)
- const [saving, setSaving] = createSignal(false)
- const [reviewState] = createResource(
- () => (props.visible && props.sessionID ? { sessionID: props.sessionID, version: memoryStateVersion() } : undefined),
- async () => {
- const result = await sdk.client.memory.reviewState()
- return (result.data ?? {}) as MemoryState
- },
- )
-
- const show = () => props.visible && !dismissed() && reviewState.latest?.disabled === false && reviewState.latest?.status === "ok"
-
- createEffect(() => {
- props.sessionID
- setDraft("")
- setDismissed(false)
- })
-
- const accept = async () => {
- const text = draft().trim()
- if (!text || saving()) return
- setSaving(true)
- try {
- const result = await sdk.client.memory.acceptProposal({ memoryProposalInput: { text, scope: "project" } })
- const blocked = memoryError(result.error)
- if (blocked?.error === "memory_disabled" || blocked?.error === "memory_safe_mode") {
- setDraft("")
- setDismissed(true)
- showToast({
- variant: "subtle",
- title: language.t("memory.review.blocked"),
- description: language.t(blocked.error === "memory_disabled" ? "memory.review.blockedDisabled" : "memory.review.blockedSafeMode"),
- })
- return
- }
- const state = (result.data ?? {}) as MemoryState
- if (state.disabled || state.status === "safe_mode") {
- setDraft("")
- setDismissed(true)
- showToast({
- variant: "subtle",
- title: language.t("memory.review.blocked"),
- description: language.t(state.disabled ? "memory.review.blockedDisabled" : "memory.review.blockedSafeMode"),
- })
- return
- }
- setDraft("")
- setDismissed(true)
- showToast({
- variant: "success",
- title: language.t("memory.review.saved"),
- description: language.t("memory.review.savedDescription"),
- })
- } catch (error) {
- const blocked = memoryError(error)
- if (blocked?.error === "memory_disabled" || blocked?.error === "memory_safe_mode") {
- setDraft("")
- setDismissed(true)
- showToast({
- variant: "subtle",
- title: language.t("memory.review.blocked"),
- description: language.t(blocked.error === "memory_disabled" ? "memory.review.blockedDisabled" : "memory.review.blockedSafeMode"),
- })
- return
- }
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: errorMessage(error, language.t("common.requestFailed")),
- })
- } finally {
- setSaving(false)
- }
- }
-
- return (
-
-
- {language.t("memory.review.title")}
-
-
- )
-}
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 15a387587..a433e3a53 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -857,20 +857,11 @@ export const dict = {
"settings.memory.safeMode": "Memory is in safe mode. Reason: {{reason}}. Reset to template or fix the file manually.",
"settings.memory.profileTooLarge": "Profile is larger than the v1 soft limit. Keep it short because it loads at session start.",
"settings.memory.raw.title": "Raw MEMORY.md",
- "settings.memory.raw.description": "Profile updates in v1 are made here. Archive entries are searched only when needed.",
+ "settings.memory.raw.description": "Profile loads at session start. Archive stays local and is searched only when needed.",
"settings.memory.saved": "Memory saved",
"settings.memory.reset": "Reset to template",
"settings.memory.resetConfirm": "This will overwrite MEMORY.md. The previous file is saved as a backup. Continue?",
"settings.memory.resetDone": "Memory reset",
- "memory.review.title": "Remember this session?",
- "memory.review.placeholder": "Write one approved memory from this session...",
- "memory.review.accept": "Save memory",
- "memory.review.dismiss": "Dismiss",
- "memory.review.saved": "Memory saved",
- "memory.review.savedDescription": "Saved entry was re-read from MEMORY.md after redaction.",
- "memory.review.blocked": "Memory was not saved",
- "memory.review.blockedDisabled": "Memory is disabled.",
- "memory.review.blockedSafeMode": "Memory is in safe mode. Reset to template or fix MEMORY.md manually.",
"settings.tab.worktrees": "Worktrees",
"settings.worktrees.title": "Worktrees",
"settings.worktrees.description": "Worktrees PawWork has created or registered. An active session keeps its worktree bound; run ExitWorktree before you can delete it.",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index 4663b0175..9179c8e85 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -746,20 +746,11 @@ export const dict = {
"settings.memory.safeMode": "记忆处于安全模式。原因:{{reason}}。请重置模板或手动修复文件。",
"settings.memory.profileTooLarge": "Profile 超过 v1 建议大小。它会在会话启动时加载,建议保持很短。",
"settings.memory.raw.title": "原始 MEMORY.md",
- "settings.memory.raw.description": "v1 中 Profile 更新通过这里手动编辑。Archive 只在需要时搜索。",
+ "settings.memory.raw.description": "Profile 会在会话启动时加载。Archive 留在本地,只在需要时搜索。",
"settings.memory.saved": "记忆已保存",
"settings.memory.reset": "重置为模板",
"settings.memory.resetConfirm": "这会覆盖 MEMORY.md。旧文件会先备份。继续吗?",
"settings.memory.resetDone": "记忆已重置",
- "memory.review.title": "要记住这次会话吗?",
- "memory.review.placeholder": "写一条本次会话确认要保存的记忆...",
- "memory.review.accept": "保存记忆",
- "memory.review.dismiss": "跳过",
- "memory.review.saved": "记忆已保存",
- "memory.review.savedDescription": "保存后已从 MEMORY.md 重新读取,敏感内容会被打码。",
- "memory.review.blocked": "记忆未保存",
- "memory.review.blockedDisabled": "记忆已关闭。",
- "memory.review.blockedSafeMode": "记忆处于安全模式。请重置模板或手动修复 MEMORY.md。",
"settings.tab.worktrees": "工作树",
"settings.worktrees.title": "工作树",
"settings.worktrees.description": "爪印创建或登记过的工作树。正在使用中的会话会保持绑定,先调用 ExitWorktree 才能删除。",
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 5de9b7c38..101127680 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -655,7 +655,6 @@ export default function Page() {
anchor={timelineInteraction.anchor}
onRetryOpenSession={retryOpenRouteSession}
onOpenNewSession={openNewRouteSession}
- memoryReviewVisible={!!timelineSessionID() && !timelineRunning()}
composerSession={renderComposerRegion("session")}
composerHome={(ctx) => renderComposerRegion("home", ctx)}
canReview={canReview}
diff --git a/packages/app/src/pages/session/session-main-view.tsx b/packages/app/src/pages/session/session-main-view.tsx
index 09e075639..1fb18ca8d 100644
--- a/packages/app/src/pages/session/session-main-view.tsx
+++ b/packages/app/src/pages/session/session-main-view.tsx
@@ -1,7 +1,6 @@
import { Match, Show, Switch, type ComponentProps, type JSX } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { NewSessionView, SessionHeader } from "@/components/session"
-import { SessionMemoryReview } from "@/components/memory/session-memory-review"
import type { PawworkSkillName } from "@/components/session/pawwork-skill-meta"
import type { useLanguage } from "@/context/language"
import type { createSizing } from "@/pages/session/helpers"
@@ -49,7 +48,6 @@ export function SessionMainView(props: {
anchor: TimelineProps["anchor"]
onRetryOpenSession: () => void
onOpenNewSession: () => void
- memoryReviewVisible: boolean
composerSession: JSX.Element
composerHome: (ctx: {
onModeChange: (mode: "normal" | "shell") => void
@@ -178,7 +176,6 @@ export function SessionMainView(props: {
-
{props.composerSession}
diff --git a/packages/app/src/shell-frame-contract.test.ts b/packages/app/src/shell-frame-contract.test.ts
index 9845e5efc..4e22ecd12 100644
--- a/packages/app/src/shell-frame-contract.test.ts
+++ b/packages/app/src/shell-frame-contract.test.ts
@@ -158,9 +158,6 @@ test("session composer is docked outside the scroll-clipped timeline region", ()
expect(session).toContain("const renderComposerRegion = (")
expect(session).toContain('variant: "session" | "home"')
expect(sessionMainView).toContain('
')
- expect(sessionMainView).toContain(
- '',
- )
expect(sessionMainView).toContain(
"
\n ",
)
diff --git a/packages/opencode/src/memory/memory.ts b/packages/opencode/src/memory/memory.ts
index b43304595..bb473ec3a 100644
--- a/packages/opencode/src/memory/memory.ts
+++ b/packages/opencode/src/memory/memory.ts
@@ -1,7 +1,4 @@
-import crypto from "crypto"
-
export namespace MemoryFile {
- export type Scope = "user" | "project"
export type SafeModeReason =
| "missing_profile"
| "missing_archive"
@@ -9,29 +6,11 @@ export namespace MemoryFile {
| "duplicate_archive"
| "sections_out_of_order"
- export type Entry = {
- id: string
- createdAt: string
- scope: Scope
- appliesTo?: string
- heading: string
- body: string
- raw: string
- }
-
- export type InvalidEntry = {
- heading: string
- raw: string
- reason: string
- }
-
export type ParseResult =
| {
status: "ok"
profile: string
archive: string
- entries: Entry[]
- invalidEntries: InvalidEntry[]
profileTooLarge: boolean
}
| { status: "safe_mode"; reason: SafeModeReason }
@@ -61,10 +40,6 @@ export namespace MemoryFile {
].join("\n")
}
- export function makeID() {
- return `mem_${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`
- }
-
export function parse(input: string): ParseResult {
const sections = parseSections(input)
if (sections.status === "safe_mode") return sections
@@ -74,13 +49,10 @@ export namespace MemoryFile {
const archiveBodyStart = nextLineIndex(input, archiveStart)
const profile = input.slice(profileBodyStart, archiveStart).trim()
const archive = input.slice(archiveBodyStart).trim()
- const { entries, invalidEntries } = parseEntries(archive)
return {
status: "ok",
profile,
archive,
- entries,
- invalidEntries,
profileTooLarge: profile.length > PROFILE_CONTEXT_LIMIT,
}
}
@@ -118,91 +90,8 @@ export namespace MemoryFile {
return { status: "ok", profileStart, archiveStart }
}
- export function formatEntry(input: {
- id?: string
- createdAt?: string
- scope: Scope
- appliesTo?: string
- text: string
- }) {
- const id = input.id ?? makeID()
- const createdAt = input.createdAt ?? new Date().toISOString()
- const scope = input.scope === "project" ? `scope:project applies_to:${encodeURIComponent(input.appliesTo ?? "")}` : "scope:user"
- return `### ${createdAt} id:${id} ${scope}\n${input.text.trim()}\n`
- }
-
function nextLineIndex(input: string, start: number) {
const offset = input.slice(start).indexOf("\n")
return offset === -1 ? input.length : start + offset + 1
}
-
- function parseEntries(archive: string): { entries: Entry[]; invalidEntries: InvalidEntry[] } {
- const headingPattern = /^### \d{4}-\d{2}-\d{2}T[^\n]*\bid:[^\s]+[^\n]*$/gm
- const headings = [...archive.matchAll(headingPattern)]
- const chunks = headings
- .map((match, index) => archive.slice(match.index!, headings[index + 1]?.index ?? archive.length))
- .map((chunk) => chunk.trim())
- .filter(Boolean)
- const seen = new Set()
- const entries: Entry[] = []
- const invalidEntries: InvalidEntry[] = []
-
- for (const raw of chunks) {
- const [heading = "", ...bodyLines] = raw.split("\n")
- if (!heading.startsWith("### ")) continue
- const parsed = parseHeading(heading)
- if (!parsed.ok) {
- invalidEntries.push({ heading, raw, reason: parsed.reason })
- continue
- }
- if (seen.has(parsed.entry.id)) {
- invalidEntries.push({ heading, raw, reason: "duplicate_id" })
- continue
- }
- seen.add(parsed.entry.id)
- const body = bodyLines.join("\n").trim()
- entries.push({ ...parsed.entry, heading, body, raw })
- }
-
- return { entries, invalidEntries }
- }
-
- function parseHeading(
- heading: string,
- ): { ok: true; entry: Omit } | { ok: false; reason: string } {
- const rest = heading.slice(4).trim()
- const [createdAt, ...tokens] = rest.split(/\s+/)
- if (!createdAt || Number.isNaN(Date.parse(createdAt))) return { ok: false, reason: "invalid_timestamp" }
- const meta = Object.fromEntries(
- tokens.map((token) => {
- const index = token.indexOf(":")
- return index === -1 ? [token, ""] : [token.slice(0, index), token.slice(index + 1)]
- }),
- )
- const id = meta.id
- if (!id) return { ok: false, reason: "missing_id" }
- if (meta.scope !== "user" && meta.scope !== "project") return { ok: false, reason: "invalid_scope" }
- if (meta.scope === "project" && !meta.applies_to) return { ok: false, reason: "missing_applies_to" }
-
- const appliesTo = meta.applies_to
- ? (() => {
- try {
- return decodeURIComponent(meta.applies_to)
- } catch {
- return meta.applies_to
- }
- })()
- : undefined
-
- return {
- ok: true,
- entry: {
- id,
- createdAt,
- scope: meta.scope,
- appliesTo,
- heading,
- },
- }
- }
}
diff --git a/packages/opencode/src/memory/proposal.ts b/packages/opencode/src/memory/proposal.ts
deleted file mode 100644
index 4f19168b1..000000000
--- a/packages/opencode/src/memory/proposal.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { MemoryFile } from "./memory"
-
-const SENSITIVE_PATTERNS = [
- /sk-[A-Za-z0-9_-]{8,}/g,
- /ghp_[A-Za-z0-9_]{8,}/g,
- /github_pat_[A-Za-z0-9_]{8,}/g,
- /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
- /\bpassword\s*[:=]\s*\S+/gi,
-]
-
-export namespace MemoryProposal {
- export type Proposal = {
- id: string
- text: string
- scope: MemoryFile.Scope
- defaultSelected: boolean
- warning?: string
- }
-
- export function redact(input: string) {
- let text = input
- let highRisk = false
- for (const pattern of SENSITIVE_PATTERNS) {
- pattern.lastIndex = 0
- if (pattern.test(text)) highRisk = true
- pattern.lastIndex = 0
- text = text.replace(pattern, "[REDACTED]")
- }
- return { text, highRisk }
- }
-
- export function fromText(input: { text: string; scope?: MemoryFile.Scope }): Proposal {
- const redacted = redact(input.text)
- return {
- id: MemoryFile.makeID(),
- text: redacted.text,
- scope: input.scope ?? "project",
- defaultSelected: !redacted.highRisk,
- warning: redacted.highRisk ? "Contains possible sensitive data." : undefined,
- }
- }
-}
diff --git a/packages/opencode/src/memory/service.ts b/packages/opencode/src/memory/service.ts
index 481ed0fb1..2efd36efe 100644
--- a/packages/opencode/src/memory/service.ts
+++ b/packages/opencode/src/memory/service.ts
@@ -2,7 +2,6 @@ import fs from "fs/promises"
import path from "path"
import { PawWorkHome } from "@opencode-ai/core/pawwork-home"
import { MemoryFile } from "./memory"
-import { MemoryProposal } from "./proposal"
export namespace MemoryService {
export type State = {
@@ -13,7 +12,6 @@ export namespace MemoryService {
content: string
profile?: string
profileTooLarge?: boolean
- invalidEntries?: MemoryFile.InvalidEntry[]
}
export type ProfileState = {
@@ -44,7 +42,6 @@ export namespace MemoryService {
const home = input?.home ?? PawWorkHome.primary()
const file = path.join(home, "memory", "MEMORY.md")
const disabledFile = path.join(home, "memory", ".disabled")
- const workspacePath = input?.workspacePath ?? process.cwd()
async function ensure() {
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
@@ -77,7 +74,6 @@ export namespace MemoryService {
content,
profile: parsed.profile,
profileTooLarge: parsed.profileTooLarge,
- invalidEntries: parsed.invalidEntries,
}
}
@@ -125,11 +121,10 @@ export namespace MemoryService {
async function deleteEntry(id: string) {
await enqueueWrite(file, async () => {
const state = await read()
- const parsed = MemoryFile.parse(state.content)
- if (parsed.status !== "ok") throw new Error("Memory is in safe mode")
+ if (state.status !== "ok") throw new Error("Memory is in safe mode")
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const next = state.content.replace(
- new RegExp(`\\n?### \\d{4}-\\d{2}-\\d{2}T[^\\n]*\\bid:${escaped}(?:\\s|$)[\\s\\S]*?(?=\\n### \\d{4}-\\d{2}-\\d{2}T[^\\n]*\\bid:|$)`),
+ new RegExp(`\\n?### [^\\n]*\\bid:${escaped}(?:\\s|$)[\\s\\S]*?(?=\\n### [^\\n]*\\bid:|$)`),
"",
)
if (next === state.content) throw new Error(`Memory entry not found: ${id}`)
@@ -145,23 +140,6 @@ export namespace MemoryService {
})
}
- async function appendAcceptedProposal(input: { text: string; scope: MemoryFile.Scope }) {
- return enqueueWrite(file, async () => {
- const state = await read()
- if (state.disabled) throw new Error("Memory is disabled")
- const parsed = MemoryFile.parse(state.content)
- if (parsed.status !== "ok") throw new Error("Memory is in safe mode")
- const redacted = MemoryProposal.redact(input.text)
- const entry = MemoryFile.formatEntry({
- scope: input.scope,
- appliesTo: input.scope === "project" ? workspacePath : undefined,
- text: redacted.text,
- })
- await writeAtomic(`${state.content.trimEnd()}\n\n${entry}`)
- return entry
- })
- }
-
return {
isDisabled,
read,
@@ -170,7 +148,6 @@ export namespace MemoryService {
resetToTemplate,
deleteEntry,
setDisabled,
- appendAcceptedProposal,
}
}
diff --git a/packages/opencode/src/server/instance/memory.ts b/packages/opencode/src/server/instance/memory.ts
index 6036a1c3b..ffc9db228 100644
--- a/packages/opencode/src/server/instance/memory.ts
+++ b/packages/opencode/src/server/instance/memory.ts
@@ -6,12 +6,6 @@ import { MemoryService } from "@/memory/service"
const MemoryRawInput = z.object({ content: z.string() }).meta({ ref: "MemoryRawInput" })
const MemoryDisabledInput = z.object({ disabled: z.boolean() }).meta({ ref: "MemoryDisabledInput" })
-const MemoryProposalInput = z
- .object({
- text: z.string(),
- scope: z.enum(["user", "project"]),
- })
- .meta({ ref: "MemoryProposalInput" })
const MemoryState = z.any().meta({ ref: "MemoryState" })
@@ -32,17 +26,6 @@ export const MemoryRoutes = () =>
}),
async (c) => c.json(await service().read()),
)
- .get(
- "/review-state",
- describeRoute({
- summary: "Get PawWork memory review availability",
- operationId: "memory.reviewState",
- responses: {
- 200: { description: "Memory review state", content: { "application/json": { schema: resolver(MemoryState) } } },
- },
- }),
- async (c) => c.json(await service().readProfile()),
- )
.patch(
"/",
describeRoute({
@@ -106,25 +89,3 @@ export const MemoryRoutes = () =>
return c.json(await service().read())
},
)
- .post(
- "/proposal/accept",
- describeRoute({
- summary: "Accept PawWork memory proposal",
- operationId: "memory.acceptProposal",
- responses: {
- 200: { description: "Memory state", content: { "application/json": { schema: resolver(MemoryState) } } },
- },
- }),
- validator("json", MemoryProposalInput),
- async (c) => {
- try {
- await service().appendAcceptedProposal(c.req.valid("json"))
- return c.json(await service().read())
- } catch (error) {
- const reason = error instanceof Error ? error.message : String(error)
- if (reason === "Memory is disabled") return c.json({ error: "memory_disabled", reason }, 409)
- if (reason === "Memory is in safe mode") return c.json({ error: "memory_safe_mode", reason }, 409)
- throw error
- }
- },
- )
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 83fc08499..fb0483b81 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -1829,8 +1829,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const memoryProfile = Runtime.isPawWork()
? yield* Effect.promise(async () => {
const shellQuote = (value: string) => `'${value.replace(/'/g, "'\\''")}'`
- const grepPattern = (value: string) => value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")
- const workspaceMemoryKey = encodeURIComponent(session.directory)
const state = await MemoryService.create({ workspacePath: session.directory }).readProfile()
if (state.disabled || state.status !== "ok") return undefined
const profile = state.profile?.trim()
@@ -1842,11 +1840,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the
"Long-form historical context lives in the Archive section of this file:",
state.path,
"",
- "When you need prior context, use Bash grep on that file. Keep results short and only use entries with scope:user or entries whose applies_to matches the encoded current workspace path.",
+ "Only Profile is loaded automatically. Archive is not injected. When you need prior memory, use Bash grep/read on that file and keep results short.",
`Current workspace path: ${session.directory}`,
- `Encoded workspace path: ${workspaceMemoryKey}`,
- `Example: grep -A 5 -E ${shellQuote(`scope:user|applies_to:${grepPattern(workspaceMemoryKey)}`)} ${shellQuote(state.path)} | head -c 2000`,
+ `Example: grep -n -i ${shellQuote("")} ${shellQuote(state.path)} | head -20 | head -c 2000`,
"Do not inject more than 2000 characters of Archive memory into context.",
+ "",
+ "At reply/task closeout, if the user explicitly stated a stable long-lived preference, workflow, project convention, or durable fact worth future recall, update this MEMORY.md file with existing file editing tools.",
+ "Only write stable explicit facts. Do not write temporary tasks, emotions, one-off decisions, guesses, or unconfirmed facts. If a new fact conflicts with an old memory, update the old memory instead of appending a contradiction.",
+ "Record only what the user has stated explicitly. Do not extrapolate preferences from how the user phrases questions or what tasks they request. For example, do not turn \"the user asked me to change one PR line\" into \"the user prefers small PRs.\"",
+ "Never write passwords, API keys, tokens, private keys, ID/passport/license numbers, credit card/bank/CVV data, private health records, home addresses, or private phone numbers.",
+ "Keep Profile short because it is loaded at session start. Put longer history in Archive.",
+ "If this is the first time you auto-write to memory and MEMORY.md was effectively empty or still only had the default template, mention it briefly and naturally in the normal reply, e.g. \"I'll remember this preference for future chats.\" After this first time, subsequent auto-writes are silent.",
+ "Do not show toast, dialog, or inline UI feedback. If the user explicitly asks you to remember something, acknowledge naturally in the normal reply, e.g. \"Got it, I'll remember that.\"",
"",
].join("\n")
}).pipe(
diff --git a/packages/opencode/test/memory/pawwork-memory.test.ts b/packages/opencode/test/memory/pawwork-memory.test.ts
index b87ffac0c..bcefa5e4f 100644
--- a/packages/opencode/test/memory/pawwork-memory.test.ts
+++ b/packages/opencode/test/memory/pawwork-memory.test.ts
@@ -3,7 +3,6 @@ import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { MemoryFile } from "../../src/memory/memory"
-import { MemoryProposal } from "../../src/memory/proposal"
import { MemoryService } from "../../src/memory/service"
describe("PawWork memory parser", () => {
@@ -29,7 +28,7 @@ describe("PawWork memory parser", () => {
expect(parsed.reason).toBe("sections_out_of_order")
})
- test("parses Archive entries with user and project scopes only", () => {
+ test("keeps Archive as freeform markdown", () => {
const parsed = MemoryFile.parse(`
# PawWork Memory
@@ -44,23 +43,23 @@ Project memory.
`)
expect(parsed.status).toBe("ok")
if (parsed.status !== "ok") throw new Error("expected ok parse")
- expect(parsed.entries).toHaveLength(1)
- expect(parsed.entries[0]?.id).toBe("mem_abc")
- expect(parsed.entries[0]?.scope).toBe("project")
+ expect(parsed.archive).toContain("scope:project")
+ expect(parsed.archive).toContain("Project memory.")
})
- test("round-trips project paths with spaces", () => {
- const entry = MemoryFile.formatEntry({
- id: "mem_space",
- createdAt: "2026-05-10T18:00:00+09:00",
- scope: "project",
- appliesTo: "/repo/Paw Work",
- text: "Project memory.",
- })
- const parsed = MemoryFile.parse(`# PawWork Memory\n\n## Profile\n\n## Archive\n\n${entry}`)
+ test("allows Archive entries without metadata", () => {
+ const parsed = MemoryFile.parse(`# PawWork Memory
+
+## Profile
+
+## Archive
+
+### 2026-05-10 Decision
+Use one global MEMORY.md file.
+`)
expect(parsed.status).toBe("ok")
if (parsed.status !== "ok") throw new Error("expected ok parse")
- expect(parsed.entries[0]?.appliesTo).toBe("/repo/Paw Work")
+ expect(parsed.archive).toContain("Use one global MEMORY.md")
})
test("does not split Archive entries on markdown body headings", () => {
@@ -78,11 +77,11 @@ Still the same entry.
`)
expect(parsed.status).toBe("ok")
if (parsed.status !== "ok") throw new Error("expected ok parse")
- expect(parsed.entries).toHaveLength(1)
- expect(parsed.entries[0]?.body).toContain("### Body heading")
+ expect(parsed.archive).toContain("### Body heading")
+ expect(parsed.archive).toContain("Still the same entry.")
})
- test("marks global scope invalid in v1", () => {
+ test("does not safe-mode on old or malformed Archive metadata", () => {
const parsed = MemoryFile.parse(`
# PawWork Memory
@@ -95,8 +94,7 @@ Bad entry.
`)
expect(parsed.status).toBe("ok")
if (parsed.status !== "ok") throw new Error("expected ok parse")
- expect(parsed.entries).toHaveLength(0)
- expect(parsed.invalidEntries).toHaveLength(1)
+ expect(parsed.archive).toContain("scope:global")
})
test("parses Profile-only startup state without parsing Archive entries", () => {
@@ -135,39 +133,65 @@ describe("PawWork memory service", () => {
expect(await fs.readFile(path.join(dir, "memory", "MEMORY.md"), "utf8")).toContain("## Profile")
})
- test("redacts high-risk tokens and defaults proposal to unselected", () => {
- const proposal = MemoryProposal.fromText({ text: "Use token sk-test123456" })
- expect(proposal.text).toContain("[REDACTED]")
- expect(proposal.defaultSelected).toBe(false)
- expect(proposal.warning).toContain("sensitive")
- })
-
- test("create append read delete smoke path", async () => {
+ test("create save read delete smoke path", async () => {
await using tmp = await tmpdir()
const dir = tmp.path
const service = MemoryService.createForTest({ home: dir, workspacePath: "/repo/pawwork" })
await service.read()
- await service.appendAcceptedProposal({ text: "PawWork uses one MEMORY.md file.", scope: "project" })
- const appended = await service.read()
- expect(appended.content).toContain("scope:project")
- const id = appended.content.match(/id:(mem_[a-z0-9]+)/)?.[1]
- expect(id).toBeTruthy()
- await service.deleteEntry(id!)
+ await service.saveRaw(`# PawWork Memory
+
+## Profile
+
+- PawWork uses one MEMORY.md file.
+
+## Archive
+
+### 2026-05-10 id:mem_manual
+Historical note.
+`)
+ const saved = await service.read()
+ expect(saved.content).toContain("mem_manual")
+ await service.deleteEntry("mem_manual")
const deleted = await service.read()
- expect(deleted.content).not.toContain(id!)
+ expect(deleted.content).not.toContain("mem_manual")
+ })
+
+ test("deleteEntry ignores markdown body headings without memory ids", async () => {
+ await using tmp = await tmpdir()
+ const service = MemoryService.createForTest({ home: tmp.path, workspacePath: "/repo/pawwork" })
+ await service.saveRaw(`# PawWork Memory
+
+## Profile
+
+## Archive
+
+### 2026-05-10 id:mem_first
+First note.
+### Body heading
+Still first note.
+
+### 2026-05-11 id:mem_second
+Second note.
+`)
+ await service.deleteEntry("mem_first")
+ const state = await service.read()
+ expect(state.content).not.toContain("mem_first")
+ expect(state.content).not.toContain("Still first note.")
+ expect(state.content).toContain("mem_second")
+ expect(state.content).toContain("Second note.")
})
- test("serializes concurrent proposal appends", async () => {
+ test("serializes concurrent raw saves", async () => {
await using tmp = await tmpdir()
const service = MemoryService.createForTest({ home: tmp.path, workspacePath: "/repo/pawwork" })
await service.read()
await Promise.all([
- service.appendAcceptedProposal({ text: "First memory.", scope: "project" }),
- service.appendAcceptedProposal({ text: "Second memory.", scope: "project" }),
+ service.saveRaw("# PawWork Memory\n\n## Profile\n\n- First memory.\n\n## Archive\n"),
+ service.saveRaw("# PawWork Memory\n\n## Profile\n\n- Second memory.\n\n## Archive\n"),
])
const state = await service.read()
- expect(state.content).toContain("First memory.")
- expect(state.content).toContain("Second memory.")
+ expect(state.content).toContain("## Profile")
+ expect(["First memory.", "Second memory."].some((value) => state.content.includes(value))).toBe(true)
})
test("disabled runtime Profile read does not create or parse MEMORY.md", async () => {
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index abee222b7..e1727dd1f 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -67,15 +67,12 @@ import type {
McpLocalConfig,
McpRemoteConfig,
McpStatusResponses,
- MemoryAcceptProposalResponses,
MemoryDeleteEntryResponses,
MemoryDisabledInput,
MemoryDisabledResponses,
MemoryGetResponses,
- MemoryProposalInput,
MemoryRawInput,
MemoryResetResponses,
- MemoryReviewStateResponses,
MemoryUpdateErrors,
MemoryUpdateResponses,
OutputFormat,
@@ -3362,34 +3359,6 @@ export class Memory extends HeyApiClient {
})
}
- /**
- * Get PawWork memory review availability
- */
- public reviewState(
- parameters?: {
- directory?: string
- workspace?: string
- },
- options?: Options,
- ) {
- const params = buildClientParams(
- [parameters],
- [
- {
- args: [
- { in: "query", key: "directory" },
- { in: "query", key: "workspace" },
- ],
- },
- ],
- )
- return (options?.client ?? this.client).get({
- url: "/memory/review-state",
- ...options,
- ...params,
- })
- }
-
/**
* Reset PawWork memory
*/
@@ -3482,41 +3451,6 @@ export class Memory extends HeyApiClient {
...params,
})
}
-
- /**
- * Accept PawWork memory proposal
- */
- public acceptProposal(
- parameters?: {
- directory?: string
- workspace?: string
- memoryProposalInput?: MemoryProposalInput
- },
- options?: Options,
- ) {
- const params = buildClientParams(
- [parameters],
- [
- {
- args: [
- { in: "query", key: "directory" },
- { in: "query", key: "workspace" },
- { key: "memoryProposalInput", map: "body" },
- ],
- },
- ],
- )
- return (options?.client ?? this.client).post({
- url: "/memory/proposal/accept",
- ...options,
- ...params,
- headers: {
- "Content-Type": "application/json",
- ...options?.headers,
- ...params.headers,
- },
- })
- }
}
export class Find extends HeyApiClient {
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 1f680f872..702c5faee 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -2236,11 +2236,6 @@ export type MemoryDisabledInput = {
disabled: boolean
}
-export type MemoryProposalInput = {
- text: string
- scope: "user" | "project"
-}
-
export type Symbol = {
name: string
kind: number
@@ -5355,25 +5350,6 @@ export type MemoryUpdateResponses = {
export type MemoryUpdateResponse = MemoryUpdateResponses[keyof MemoryUpdateResponses]
-export type MemoryReviewStateData = {
- body?: never
- path?: never
- query?: {
- directory?: string
- workspace?: string
- }
- url: "/memory/review-state"
-}
-
-export type MemoryReviewStateResponses = {
- /**
- * Memory review state
- */
- 200: MemoryState
-}
-
-export type MemoryReviewStateResponse = MemoryReviewStateResponses[keyof MemoryReviewStateResponses]
-
export type MemoryResetData = {
body?: never
path?: never
@@ -5433,25 +5409,6 @@ export type MemoryDeleteEntryResponses = {
export type MemoryDeleteEntryResponse = MemoryDeleteEntryResponses[keyof MemoryDeleteEntryResponses]
-export type MemoryAcceptProposalData = {
- body?: MemoryProposalInput
- path?: never
- query?: {
- directory?: string
- workspace?: string
- }
- url: "/memory/proposal/accept"
-}
-
-export type MemoryAcceptProposalResponses = {
- /**
- * Memory state
- */
- 200: MemoryState
-}
-
-export type MemoryAcceptProposalResponse = MemoryAcceptProposalResponses[keyof MemoryAcceptProposalResponses]
-
export type FindTextData = {
body?: never
path?: never