diff --git a/App/frontend/desktop/package.json b/App/frontend/desktop/package.json index d976a93c9..9b145c753 100644 --- a/App/frontend/desktop/package.json +++ b/App/frontend/desktop/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", - "happy-dom": "^20.10.6" + "happy-dom": "^20.10.6", + "@types/hast": "^3.0.4" } } diff --git a/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx b/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx index 91a7a0870..6368257f3 100644 --- a/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx +++ b/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx @@ -956,7 +956,10 @@ function TraceMemoryDetail(props: { : detail.steps.flatMap((step) => step.toolCalls); const turnEvents = buildTraceTurnEvents(detail, toolCalls); const hasTurnSteps = turnEvents.length > 0; - const summaryText = displayMemorySummaryText(detail.summary || props.item.summary, t, props.item.processing); + const rawBody = !hasTurnSteps && props.item.body?.trim() ? props.item.body : undefined; + // The read model owns summary validity and legacy field precedence. Raw trace + // metadata may still contain a placeholder or an older copy of the summary. + const summaryText = displayMemorySummaryText(props.item.summary, t, props.item.processing); return ( <> @@ -987,11 +990,13 @@ function TraceMemoryDetail(props: {
-
{t("memory.memories.turnSteps")}
+
{t(rawBody ? "memory.memories.body" : "memory.memories.turnSteps")}
{hasTurnSteps ? (
{turnEvents.map((event) => )}
+ ) : rawBody ? ( +
{rawBody}
) : ( )} @@ -1022,7 +1027,7 @@ function TraceTurnEventBlock(props: { event: TraceTurnEvent }) { return ( - + ); } diff --git a/App/frontend/desktop/src/pages/memory/memory-display.ts b/App/frontend/desktop/src/pages/memory/memory-display.ts index 3a44cd86f..9cdefbdf6 100644 --- a/App/frontend/desktop/src/pages/memory/memory-display.ts +++ b/App/frontend/desktop/src/pages/memory/memory-display.ts @@ -16,6 +16,7 @@ const PLACEHOLDER_TITLES = new Set([ "system", "tool", "developer", + "\u6458\u8981\u603B\u7ED3\u4E2D", "\u6458\u8981\u6392\u961F\u4E2D", "\u6458\u8981\u6574\u7406\u4E2D", "\u5EFA\u7ACB\u7D22\u5F15\u4E2D", @@ -63,8 +64,9 @@ export function displayMemoryTitle(item: MemoryDisplayItem, ...candidates: Array if (generatedTitle) return generatedTitle; const summary = readySummary(item.summary); - if (item.kind === "trace" && summary) { - return summary; + if (item.kind === "trace") { + // All processing states use the read model preview, never raw source text. + return summary ?? waitingPrimaryText(item); } for (const value of [summary, ...candidates, firstUserQueryLine(item.body), firstReadableBodyLine(item.body), item.title]) { @@ -99,10 +101,9 @@ export function experienceDisplayTitle(item: MemoryDisplayItem): string | undefi export function waitingPrimaryText(item: MemoryDisplayItem): string { const source = cleanMemoryText(item.sourceText); if (usableWaitingText(source)) return source; - if (item.kind === "policy") return displayMemoryId(item.id); - return firstUserQueryLine(item.body) - ?? firstReadableBodyLine(item.body) - ?? displayMemoryId(item.id); + // The read model owns preview extraction. Re-reading raw body here would + // resurrect injected context that it deliberately excluded from sourceText. + return displayMemoryId(item.id); } export function independentGeneratedTitle(item: MemoryDisplayItem): string | undefined { diff --git a/App/frontend/desktop/src/pages/memory/memory-markdown.tsx b/App/frontend/desktop/src/pages/memory/memory-markdown.tsx index 37524bcd8..2f429a7a3 100644 --- a/App/frontend/desktop/src/pages/memory/memory-markdown.tsx +++ b/App/frontend/desktop/src/pages/memory/memory-markdown.tsx @@ -1,10 +1,12 @@ import type { ComponentPropsWithoutRef } from "react"; +import type { Root, RootContent } from "hast"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; export interface MemoryMarkdownProps { text: string; + literalHtml?: boolean; } const components: Components = { @@ -36,7 +38,7 @@ const components: Components = { export function MemoryMarkdown(props: MemoryMarkdownProps) { return (
- + {props.text}
@@ -46,3 +48,25 @@ export function MemoryMarkdown(props: MemoryMarkdownProps) { function MemoryMarkdownHeading(props: ComponentPropsWithoutRef<"h4">) { return

{props.children}

; } + +/** Preserve source HTML as inert text, including line breaks within raw blocks. */ +function rehypeLiteralHtml() { + return (tree: Root) => { + function preserve(nodes: RootContent[]): void { + for (const [index, node] of nodes.entries()) { + if (node.type === "raw") { + // Never parse source markup into elements or copy its attributes. + nodes[index] = { + type: "element", + tagName: "span", + properties: { className: ["memory-markdown__literal-html"] }, + children: [{ type: "text", value: node.value }] + }; + } else if ("children" in node) { + preserve(node.children); + } + } + } + preserve(tree.children); + }; +} diff --git a/App/frontend/desktop/src/pages/memory/memory-panel-cache.ts b/App/frontend/desktop/src/pages/memory/memory-panel-cache.ts index 3efeadf59..80c2eb6ab 100644 --- a/App/frontend/desktop/src/pages/memory/memory-panel-cache.ts +++ b/App/frontend/desktop/src/pages/memory/memory-panel-cache.ts @@ -1,5 +1,6 @@ const CACHE_PREFIX = "memmy.memory-panel."; -const CACHE_VERSION = 1; +// Invalidate source previews and summaries manufactured from legacy bodies. +const CACHE_VERSION = 3; const CACHE_TTL_MS = 30_000; interface CacheEnvelope { diff --git a/App/frontend/desktop/src/pages/memory/tasks-sub-page.tsx b/App/frontend/desktop/src/pages/memory/tasks-sub-page.tsx index 3102314c5..60cad9751 100644 --- a/App/frontend/desktop/src/pages/memory/tasks-sub-page.tsx +++ b/App/frontend/desktop/src/pages/memory/tasks-sub-page.tsx @@ -503,7 +503,7 @@ function TaskChatBubble(props: { message: TaskChatMessage }) { {message.role === "tool" && message.tool ? ( ) : ( - + )} diff --git a/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx index 8337a8235..04af26419 100644 --- a/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx @@ -26,6 +26,20 @@ import { } from "../memory-agent-filter.js"; describe("MemoriesSubPage", () => { + it("uses the canonical detail summary when legacy trace metadata still has a placeholder", () => { + const detail = { ...memoryDetailFixture, item: { ...memoryDetailFixture.item, + summary: "有效存储摘要", processing: undefined, + metadata: { ...memoryDetailFixture.item.metadata, traceDetail: { + ...(memoryDetailFixture.item.metadata.traceDetail as Record), summary: "摘要总结中" + } } + } }; + const html = renderMemories({ status: "ready", data: panelItemsOutput([]), detail: { status: "ready", data: detail } }); + expect(html).toContain("有效存储摘要"); + expect(html).not.toContain("摘要总结中"); + detail.item.summary = ""; + const empty = renderMemories({ status: "ready", data: panelItemsOutput([]), detail: { status: "ready", data: detail } }); + expect(empty).not.toContain("摘要总结中"); + }); it("在所有记忆列表明确标注没有提炼为记忆的观察和原因", () => { const html = renderMemories({ status: "ready", data: panelItemsOutput([{ ...memoryListItemFixture, @@ -38,6 +52,20 @@ describe("MemoriesSubPage", () => { expect(html).toContain("重复的窗口切换没有提供新上下文。"); expect(html).not.toContain("memory-card__score"); }); + + it("shows source envelopes and adjacent requests in the user detail block", () => { + const detail = { ...memoryDetailFixture, item: { ...memoryDetailFixture.item, + metadata: { ...memoryDetailFixture.item.metadata, traceDetail: { + ...(memoryDetailFixture.item.metadata.traceDetail as Record), + userQuery: '\n上下文\n\n请保留记忆请求。' + } } + } }; + const html = renderMemories({ status: "ready", data: panelItemsOutput([memoryListItemFixture]), detail: { status: "ready", data: detail } }); + expect(html).toContain("请保留记忆请求。"); + expect(html).toContain("<in-app-browser-context>"); + expect(html).not.toContain(""); + }); + it("把搜索转换为只读取原始记忆的 PanelItemsInput", () => { expect(buildPanelItemsInput({ query: "偏好" })).toEqual({ q: "偏好", @@ -330,7 +358,7 @@ describe("MemoriesSubPage", () => { expect(html).not.toContain('class="memory-card__title">trace_457c41a4ec1e730ecd39'); }); - it("waiting 列表的 RawTurn 标题不盖过正文里的用户原句", () => { + it("waiting 列表缺少 sourceText 时使用 id,不重新提取原始正文", () => { const html = renderMemories({ status: "ready", data: panelItemsOutput([{ @@ -345,11 +373,65 @@ describe("MemoriesSubPage", () => { }]), detail: null }); - expect(html).toContain('class="memory-card__title">请修复自动扫描卡顿'); + expect(html).toContain('class="memory-card__title">trace_waiting_raw'); + expect(html).not.toContain('class="memory-card__title">请修复自动扫描卡顿'); expect(html).toContain("摘要总结中"); expect(html).not.toContain("RawTurn: raw_pending"); }); + it.each(["summary_pending", "summarizing"] as const)("%s 不从正文恢复已过滤的上下文或占位状态", (state) => { + for (const sourceText of [undefined, "摘要总结中"]) { + const html = renderMemories({ + status: "ready", + data: panelItemsOutput([{ + ...memoryListItemFixture, + id: "trace_context_only", title: "today", + generatedTitle: undefined, sourceText, summary: "", + body: 'User:\n\n页面正文\n\nAssistant:\nDone', + processing: processingRecord("trace_context_only", state, "summary") + }]), detail: null + }); + expect(html).toContain('class="memory-card__title">trace_context_only'); + expect(html).toContain("摘要总结中"); + expect(html).not.toContain("in-app-browser-context"); + expect(html).not.toContain("页面正文"); + expect(html).not.toContain("timestamp"); + } + }); + + it.each(["failed", "ready"] as const)("%s 的 trace 使用语义预览或 ID,不能恢复历史标签", (state) => { + for (const summary of ["", "摘要总结中"]) { + for (const sourceText of ["请修复标题", undefined, "摘要总结中"]) { + const html = renderMemories({ + status: "ready", + data: panelItemsOutput([{ + ...memoryListItemFixture, kind: "trace", id: "trace_context_fallback", + title: "today", generatedTitle: undefined, summary, sourceText, + body: 'User:\n\n页面正文\n\nAssistant:\nDone', + processing: { ...processingRecord("trace_context_fallback", "ready", "summary"), state } + }]), detail: null + }); + expect(html).toContain(`class="memory-card__title">${sourceText === "请修复标题" ? sourceText : "trace_context_fallback"}`); + expect(html).not.toContain("in-app-browser-context"); + expect(html).not.toContain("timestamp"); + expect(html).not.toContain("页面正文"); + } + } + }); + + it("摘要完成进入 embedding 阶段后使用生成标题,不继续显示预览", () => { + const html = renderMemories({ + status: "ready", + data: panelItemsOutput([{ + ...memoryListItemFixture, + generatedTitle: "扫描标题修复", sourceText: "请修复扫描后的记忆标题", + processing: processingRecord(memoryListItemFixture.id, "embedding_pending", "embedding") + }]), detail: null + }); + expect(html).toContain('class="memory-card__title">扫描标题修复'); + expect(html).not.toContain('class="memory-card__title">请修复扫描后的记忆标题'); + }); + it("waiting 的记忆详情不显示标题,并把记忆摘要固定成摘要总结中", () => { const waitingDetail = { ...memoryDetailFixture, @@ -1051,7 +1133,7 @@ function renderMemories( function processingRecord( memoryId: string, - state: "summary_pending" | "embedding_pending" | "ready", + state: "summary_pending" | "summarizing" | "embedding_pending" | "ready", stage: "summary" | "embedding" | null ) { return { diff --git a/App/frontend/desktop/src/pages/memory/tests/memory-markdown.test.tsx b/App/frontend/desktop/src/pages/memory/tests/memory-markdown.test.tsx new file mode 100644 index 000000000..e1acd9dc8 --- /dev/null +++ b/App/frontend/desktop/src/pages/memory/tests/memory-markdown.test.tsx @@ -0,0 +1,74 @@ +import { readFileSync } from "node:fs"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { MemoryMarkdown } from "../memory-markdown.js"; + +const render = (text: string) => renderToStaticMarkup(); +const escape = (text: string) => text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); + +describe("MemoryMarkdown literal HTML", () => { + it("keeps HTML hidden unless literal rendering is explicitly enabled", () => { + const text = '
旧页面隐藏内容
\n\n**正常正文**'; + const html = renderToStaticMarkup(); + expect(html).not.toContain("旧页面隐藏内容"); + expect(html).not.toContain("literal-html"); + expect(html).toContain("正常正文"); + for (const file of ["world-model-sub-page.tsx", "computer-history-sub-page.tsx"]) { + const source = readFileSync(new URL(`../${file}`, import.meta.url), "utf8"); + expect(source).not.toMatch(/]*literalHtml/); + } + }); + + it.each([ + '\n示例项目\n\n给搜索框增加清空按钮。', + '\n示例项目\n\n\n给搜索框增加清空按钮。', + '\n未闭合的上下文\n给搜索框增加清空按钮。', + 'Tuesday, Jun 16, 2026', + '\n给搜索框增加清空按钮。', + '
\n多行原文\n
\n给搜索框增加清空按钮。' + ])("preserves envelope and request text: %s", (text) => { + const html = render(text); + for (const line of text.split("\n").filter(Boolean)) expect(html.replace(/<[^>]*>/g, "")).toContain(escape(line)); + expect(html).toContain('class="memory-markdown__literal-html"'); + }); + + it("preserves raw block line breaks with a scoped style", () => { + const html = render('\n第一行\n第二行\n'); + expect(html).toContain('<context>\n第一行\n第二行\n</context>'); + const css = readFileSync(new URL("../../../styles.css", import.meta.url), "utf8"); + expect(css).toMatch(/\.memory-markdown__literal-html\s*\{[^}]*white-space:\s*pre-wrap/s); + }); + + it.each([ + '', + '', + '', + '
原文
', + '原文', + '' + ])("renders active HTML only as text: %s", (text) => { + const html = render(text); + expect(html.replace(/<[^>]*>/g, "")).toContain(escape(text)); + expect(html).not.toMatch(/<(?:script|iframe|img|div\s+onclick|a\s+href|style)\b/i); + }); + + it("keeps inline tags literal and normal Markdown formatted", () => { + const html = render('正文 示例,**加粗**。\n\n- 项目\n\n| 列 |\n| --- |\n| 值 |\n\n``\n\n```html\n
代码
\n```'); + expect(html).toContain('<b>'); + expect(html).toContain('</b>'); + expect(html).toContain('加粗'); + expect(html).toContain('
  • 项目
  • '); + expect(html).toContain(''); + }); + + it("keeps existing inert links and images and accepts empty input", () => { + const html = render('[链接](https://example.invalid) ![图片说明](https://example.invalid/img)'); + expect(html).toContain('链接'); + expect(html).toContain('图片说明'); + expect(html).not.toMatch(/<(?:a|img)\b/); + expect(render('')).toBe('
    '); + }); +}); diff --git a/App/frontend/desktop/src/pages/memory/tests/memory-panel-cache.test.ts b/App/frontend/desktop/src/pages/memory/tests/memory-panel-cache.test.ts index 59b4d4a5f..501558d9c 100644 --- a/App/frontend/desktop/src/pages/memory/tests/memory-panel-cache.test.ts +++ b/App/frontend/desktop/src/pages/memory/tests/memory-panel-cache.test.ts @@ -7,6 +7,20 @@ describe("memory panel cache", () => { vi.unstubAllGlobals(); }); + it.each([1, 2])("discards fresh previews from cache version %s", (version) => { + const storage = createMemoryStorage(); + vi.stubGlobal("window", { sessionStorage: storage }); + const key = memoryPanelCacheKey("memories", "latest"); + storage.setItem(`memmy.memory-panel.${key}`, JSON.stringify({ + version, savedAt: new Date().toISOString(), + data: { sourceText: '' } + })); + expect(readMemoryPanelCache(key)).toBeNull(); + expect(storage.getItem(`memmy.memory-panel.${key}`)).toBeNull(); + writeMemoryPanelCache(key, { sourceText: "请修复标题" }); + expect(readMemoryPanelCache(key)).toEqual({ sourceText: "请修复标题" }); + }); + it("clears only memory panel cache entries", () => { const storage = createMemoryStorage(); vi.stubGlobal("window", { sessionStorage: storage }); diff --git a/App/frontend/desktop/src/pages/memory/tests/memory-title-read-model.integration.test.ts b/App/frontend/desktop/src/pages/memory/tests/memory-title-read-model.integration.test.ts new file mode 100644 index 000000000..d1fc2f4d3 --- /dev/null +++ b/App/frontend/desktop/src/pages/memory/tests/memory-title-read-model.integration.test.ts @@ -0,0 +1,240 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it } from "vitest"; +import type { MemoryRow } from "../../../../../../../Memory/src/types.js"; +import type { GetMemoryOutput, PanelItemsOutput } from "@memmy/local-api-contracts"; +import { GetMemoryOutputSchema } from "@memmy/local-api-contracts"; +import { Window } from "happy-dom"; +import { Repositories } from "../../../../../../../Memory/src/storage/repositories.js"; +import { detailFromMemory } from "../../../../../../../Memory/src/service/read-model/memory.js"; +import { waitingSourceText } from "../../../../../../../Memory/src/service/read-model/display-fields.js"; +import { createMemoryServiceFixture } from "../../../../../../../Memory/tests/fixtures/memory-service-fixture.js"; +import { MemoriesSubPageView } from "../memories-sub-page.js"; +import { I18nProvider } from "../../../i18n/i18n-provider.js"; + +const { createTestService, cleanup } = createMemoryServiceFixture(); +afterEach(cleanup); +const at = "2026-10-27T06:00:00.000Z"; +const context = '\n页面上下文\n'; +const request = "请修复扫描标题"; + +function parseHTML(html: string) { + const document = new Window({ settings: { disableJavaScriptEvaluation: true } }).document.createElement("div"); + document.innerHTML = html; + return { document }; +} + +function legacyMemory(body: string): MemoryRow { + return { + id: "trace_legacy_preview", timeline: at, userId: "review-user", agentId: "codex", + memoryType: "LongTermMemory", status: "activated", visibility: "private", + memoryValue: body, tags: [], info: {}, memoryLayer: "L1", version: 1, + // Legacy rows may omit memory_kind and trace metadata entirely. + properties: { internal_info: { memory_layer: "L1" } }, createdAt: at, updatedAt: at + }; +} + +function render(items: PanelItemsOutput["items"], detail?: GetMemoryOutput): string { + const noop = () => {}; + return renderToStaticMarkup(createElement(I18nProvider, { language: "zh-CN", children: + createElement(MemoriesSubPageView, { + state: { status: "ready", detail: detail ? { status: "ready", data: detail } : null, data: { items, page: 1, pageSize: 20, total: items.length, totalPages: 1, hasNext: false, hasPrev: false, serverTime: at } }, + query: "", sourceAgent: "", onQueryChange: noop, onSourceAgentChange: noop, + onSearch: noop, onPageChange: noop, onRefresh: noop, onOpenDetail: noop, + onDeleteDetail: async () => {}, onCloseDetail: noop + }) + })); +} + +describe("stored memory -> panel/detail -> rendered trace title", () => { + it.each([undefined, "trace"] as const)("shows a legacy body safely without manufacturing summary (kind %s)", (kind) => { + const { db, service } = createTestService(); + const repos = new Repositories(db.db); + const body = '\n\n\n\n第一行请求\n第二行请求'; + const row = legacyMemory(body); + row.properties.internal_info.memory_kind = kind; + repos.memories.insert(row); + const before = repos.memories.get(row.id)!; + const detail = GetMemoryOutputSchema.parse(service.getMemory(row.id)); + expect(detail.item.summary).toBe(""); + const { document } = parseHTML(render([], detail)); + const drawer = document.querySelector('.memory-drawer__body')!; + const original = drawer.querySelector('.memory-detail-raw-body'); + expect(original?.textContent).toBe(body); + expect(original?.children.length).toBe(0); + expect(repos.memories.get(row.id)).toEqual(before); + }); + + it("keeps structured turn rendering and empty-body handling without a duplicate raw body", () => { + const { db, service } = createTestService(); + const added = service.addMemory({ adapterId: "agent-source:codex", source: "codex", layer: "L1", turnId: "structured-detail", content: `## user\n${request}\n## assistant\n完成。` }); + const { document } = parseHTML(render([], GetMemoryOutputSchema.parse(service.getMemory(added.id)))); + const drawer = document.querySelector('.memory-drawer__body')!; + expect(drawer.textContent).toContain(request); + expect(drawer.textContent).toContain("完成。"); + expect(drawer.querySelector('.memory-detail-raw-body')).toBeNull(); + const empty = legacyMemory(" \n\t"); + new Repositories(db.db).memories.insert(empty); + const emptyHtml = render([], GetMemoryOutputSchema.parse(service.getMemory(empty.id))); + expect(emptyHtml).toContain("暂无本轮步骤"); + expect(emptyHtml).not.toContain('memory-detail-raw-body'); + }); + + it.each([ + ["system_reminder", '
    引用
    '], + ["system_reminder", '
    引用
    '], + ["pasted_content", '
    引用
    '], + ["pasted_content", '引用'] + ])("preserves imported role boundaries around tag text in %s payload: %s", (tag, payload) => { + const { db, service } = createTestService(); + const input = `<${tag}>\n${payload}\n## assistant\n引用回复\n\n${request}`; + const added = service.addMemory({ adapterId: "agent-source:codex", source: "codex", layer: "L1", turnId: "quoted-tag-import", content: `## user\n${input}\n## assistant\n实际回复` }); + expect(added.title).toBe(request); + const stored = new Repositories(db.db).memories.get(added.id)!; + const trace = stored.properties.internal_info.trace as Record; + expect(trace.userText).toBe(input); + expect(trace.agentText).toBe("实际回复"); + const item = service.panelItems({ layer: "L1" }).items[0]!; + expect(item.sourceText).toBe(request); + expect(render([item])).toContain(`class="memory-card__title">${request}`); + expect(service.getMemory(added.id).item.body).toContain(input); + }); + + it.each([ + ["L1", "span", "span body"], ["L1", "work_memory", "work body"], + ["L1", "observed_activity", "observation body"], ["L2", "policy", "policy body"], + ["L3", "world_model", "world body"], ["Skill", "skill", "skill body"] + ] as const)("preserves %s / %s summary semantics", (layer, kind, body) => { + const { db } = createTestService(); + const repos = new Repositories(db.db); + const row = legacyMemory(body); + row.memoryLayer = layer; + row.properties.internal_info.memory_kind = kind; + if (kind === "work_memory") row.properties.internal_info.requirement = "work requirement"; + const expected = kind === "work_memory" ? "work requirement" : body; + expect(repos.memories.toListItem(row).summary).toBe(expected); + expect(detailFromMemory(row).summary).toBe(expected); + }); + + it.each([ + '正文', + '截断正文', + ' { + const { db, service } = createTestService(); + const added = service.addMemory({ adapterId: "agent-source:claude-code", source: "claude-code", layer: "L1", turnId: "preview-empty", content: input }); + const row = new Repositories(db.db).memories.get(added.id)!; + const item = service.panelItems({ layer: "L1" }).items[0]!; + expect(item.sourceText).toBeUndefined(); + expect(render([item])).toContain(`class="memory-card__title">${added.id}`); + expect(detailFromMemory(row).body).toContain(input); + }); + + it("keeps storage and read-model user previews consistent around embedded role headings", () => { + const { db } = createTestService(); + const repos = new Repositories(db.db); + const input = '\n## assistant\nquoted answer\n## user\nquoted request\n\n' + request; + const row = legacyMemory(`## user\n${input}\n## assistant\nDone`); + expect(repos.memories.toListItem(row).title).toBe(request); + expect(waitingSourceText(row)).toBe(request); + }); + + it("imports an unstructured source message without splitting role headings inside its envelope", () => { + const { db, service } = createTestService(); + const input = '\n## assistant\nquoted answer\n## user\nquoted request\n\n' + request; + const added = service.addMemory({ adapterId: "agent-source:claude-code", source: "claude-code", layer: "L1", turnId: "unstructured-import", content: input }); + expect(added.title).toBe(request); + const row = new Repositories(db.db).memories.get(added.id)!; + expect((row.properties.internal_info.trace as Record).userText).toBe(input); + const item = service.panelItems({ layer: "L1" }).items[0]!; + expect(render([item])).toContain(`class="memory-card__title">${request}`); + }); + it.each([undefined, "ready", "failed", "summary_pending", "summarizing", "embedding_pending", "embedding"] as const)("does not manufacture an old trace summary in state %s", (state) => { + const { db, service } = createTestService(); + const repos = new Repositories(db.db); + for (const body of [`${context}\n${request}`, context]) { + for (const summary of [undefined, "", "摘要总结中"]) { + const row = legacyMemory(body); + row.id += `_${body === context ? "empty" : "request"}_${summary === undefined ? "missing" : summary === "" ? "blank" : "placeholder"}`; + if (summary !== undefined) row.info.summary = summary; + repos.memories.insert(row); + if (state) repos.processing.save({ memoryId: row.id, state, stage: null, activeJobId: null, attemptCount: 0, manualRetryCount: 0, retryAction: "none", errorCode: null, errorMessage: null, failedAt: null, updatedAt: at }); + const before = repos.memories.get(row.id)!; + const item = service.panelItems({ layer: "L1" }).items.find(item => item.id === row.id)!; + expect(item.summary).toBe(""); + expect(item.processing?.state).toBe(state); + expect(detailFromMemory(before).summary).toBe(""); + const detail = service.getMemory(row.id); + expect(detail.kind).toBe("trace"); + if (detail.kind === "trace") expect(detail.item.summary).toBe(""); + expect(item.sourceText).toBe(body === context ? undefined : request); + const html = render([item]); + expect(html).toContain(`class="memory-card__title">${body === context ? row.id : request}`); + expect(html).not.toContain("in-app-browser-context"); + expect(repos.memories.get(row.id)).toEqual(before); + } + } + }); + + it.each(["info", "internal", "trace"] as const)("preserves multiline summaries from %s after earlier placeholders", (field) => { + const { db, service } = createTestService(); + const repos = new Repositories(db.db); + const row = legacyMemory(`${context}\n${request}`); + const summary = "有效摘要第一行\n有效摘要第二行"; + row.info.summary = field === "info" ? summary : "摘要排队中"; + row.properties.internal_info.summary = field === "internal" ? summary : "摘要总结中"; + row.properties.internal_info.trace = { summary: field === "trace" ? summary : "", title: "生成标题" }; + repos.memories.insert(row); + const stored = repos.memories.get(row.id)!; + const item = service.panelItems({ layer: "L1" }).items[0]!; + expect(item.summary).toBe(summary); + expect(detailFromMemory(stored).summary).toBe(summary); + expect(render([item])).toContain('class="memory-card__title">生成标题'); + }); + + it.each([ + '\n粘贴正文\n', + '\n## assistant\n引用回复\n## user\n引用请求\n', + '[Image]\n附件', + '```chat_selection\n引用对话\n```' + ])("imports and renders the request following %s", (envelope) => { + const { db, service } = createTestService(); + const input = `${envelope}\n${request}`; + const added = service.addMemory({ adapterId: "agent-source:claude-code", source: "claude-code", layer: "L1", turnId: "preview-import", content: `## user\n\n${input}\n\n## assistant\n\n完成。` }); + expect(added.title).toBe(request); + const item = service.panelItems({ layer: "L1" }).items.find(item => item.id === added.id)!; + expect(item.sourceText).toBe(request); + expect(render([item])).toContain(`class="memory-card__title">${request}`); + const stored = new Repositories(db.db).memories.get(added.id)!; + expect((stored.properties.internal_info.trace as Record).userText).toBe(input); + expect(detailFromMemory(stored).body).toContain(input); + }); + + it("refreshes existing log summaries to empty without replacing missing records or span goals", () => { + const { db, service } = createTestService(); + const repos = new Repositories(db.db); + const session = service.openSession({ namespace: { source: "codex", profileId: "default", userId: "review-log" } }); + const completed = service.completeTurn("preview-log-turn", { sessionId: session.sessionId, query: request, answer: "完成。" }); + const storedLog = db.db.prepare("SELECT output_json FROM api_logs WHERE tool_name = 'memory_add'").get() as { output_json: string }; + expect(JSON.parse(storedLog.output_json).details[0].summary).toBe(""); + const row = repos.memories.get(completed.l1MemoryId)!; + row.info.summary = ""; + row.properties.internal_info.summary = ""; + (row.properties.internal_info.trace as Record).summary = ""; + repos.memories.update(row); + const log = service.apiLogs({ tools: ["memory_add"], limit: 1 }).logs[0]!; + const output = JSON.parse(log.outputJson); + output.details = [ + { role: "trace", traceId: row.id, summary: "旧标签摘要" }, + { role: "trace", traceId: "missing", summary: "历史摘要" }, + { role: "span", spanId: "missing_span", spanGoal: "历史目标" } + ]; + db.db.prepare("UPDATE api_logs SET output_json = ? WHERE id = ?").run(JSON.stringify(output), log.id); + const refreshed = JSON.parse(service.apiLogs({ tools: ["memory_add"], limit: 1 }).logs[0]!.outputJson); + expect(refreshed.details).toEqual([ + { role: "trace", traceId: row.id, summary: "" }, + { role: "trace", traceId: "missing", summary: "历史摘要" }, + { role: "span", spanId: "missing_span", spanGoal: "历史目标" } + ]); + }); +}); diff --git a/App/frontend/desktop/src/pages/memory/tests/tasks-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/tasks-sub-page.test.tsx index 95bd61dac..3db2b9444 100644 --- a/App/frontend/desktop/src/pages/memory/tests/tasks-sub-page.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/tasks-sub-page.test.tsx @@ -9,6 +9,17 @@ import { loadTasksData, TasksSubPageView } from "../tasks-sub-page.js"; import { createMemoryRuntimeClientStub } from "./fixtures.js"; describe("TasksSubPage", () => { + it("shows source envelopes and adjacent requests in task chat", () => { + const base = createTaskFixture(); + const task = { ...base, chat: base.chat.map((message) => message.role === "user" + ? { ...message, text: '\n上下文\n\n请保留任务请求。' } + : message) }; + const html = renderTasks({ status: "ready", data: tasksOutput([task]) }, task); + expect(html).toContain("请保留任务请求。"); + expect(html).toContain("<in-app-browser-context>"); + expect(html).not.toContain(""); + }); + it("直接按 episode 任务接口加载,并保留服务端分页", async () => { const listPanelTasks = vi.fn(async () => panelTasksOutput([panelTaskFixture()], { page: 2, diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index 7fa8f3e68..bbe24feb6 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -7586,6 +7586,10 @@ body.memmy-platform-windows .memory-drawer-backdrop__close { overflow-wrap: anywhere; } +.memory-markdown__literal-html { + white-space: pre-wrap; +} + .memory-markdown :first-child { margin-top: 0; } diff --git a/App/shell/desktop/tests/runtime-memory-upgrade.test.ts b/App/shell/desktop/tests/runtime-memory-upgrade.test.ts index b0953713b..a9320d7ad 100644 --- a/App/shell/desktop/tests/runtime-memory-upgrade.test.ts +++ b/App/shell/desktop/tests/runtime-memory-upgrade.test.ts @@ -184,7 +184,7 @@ describe("bundled Memory upgrades", () => { expect(await running.repairCalls()).toHaveLength(1); expect(running.children).toHaveLength(0); expect(running.old.process.exitCode).toBeNull(); - }); + }, 20_000); it("starts a Desktop child after launcher repair stops the legacy service", async () => { const running = await fixture({ version: "2.1.1", platform: "win32", repairStopsService: true }); @@ -194,7 +194,7 @@ describe("bundled Memory upgrades", () => { expect(running.children).toHaveLength(1); expect(running.children[0]?.persistOnDesktopExit).toBe(true); expect(await running.version()).toBe("2.1.1"); - }); + }, 20_000); it.each(["darwin", "linux"] as const)("does not request Windows launcher repair on %s", async (platform) => { const running = await fixture({ version: "2.1.1", platform }); @@ -220,7 +220,7 @@ describe("bundled Memory upgrades", () => { expect(running.children).toHaveLength(0); expect(running.old.process.exitCode).toBeNull(); expect(await running.version()).toBe("2.1.1"); - }); + }, 20_000); it.each(["2.1.0", "2.1.1"])("starts a Desktop child if launcher repair stopped Memory %s before task update was denied", async (version) => { const running = await fixture({ version, platform: "win32", repairFails: true, repairStopsService: true }); diff --git a/Memory/AgentSourceCore/src/tests/codex-source-turn.test.ts b/Memory/AgentSourceCore/src/tests/codex-source-turn.test.ts index 49b1c0c3f..7e4aebc31 100644 --- a/Memory/AgentSourceCore/src/tests/codex-source-turn.test.ts +++ b/Memory/AgentSourceCore/src/tests/codex-source-turn.test.ts @@ -153,6 +153,33 @@ describe("Codex native source turns", () => { expect(sourceTurnFromMessages(messages)).toMatchObject({ turnId: "turn-1", query: "Fix the issue", answer: "Done" }); }); + it("drops labelled auto-review content but keeps the next unlabelled-model turn", async () => { + const review = [ + prefix[0], + event("event_msg", { type: "task_started", turn_id: "turn-review" }), + event("turn_context", { turn_id: "turn-review", model: "codex-auto-review" }), + event("response_item", { + type: "message", role: "user", + content: [{ text: "The following is the Codex agent history whose request action you are assessing." }], + internal_chat_message_metadata_passthrough: { content_item_kinds: ["user.text"] } + }), + message("assistant", '{"outcome":"allow"}'), + event("event_msg", { type: "task_complete", turn_id: "turn-review" }) + ]; + const messages = await read([...review, ...prefix.slice(1), ...end]); + expect(sourceTurnFromMessages(messages)).toMatchObject({ turnId: "turn-1", query: "Fix the issue" }); + expect(messages.some(item => item.content.includes("Codex agent history"))).toBe(false); + }); + + it.each([undefined, "unknown-model"])("does not reject review-like user text without model evidence: %s", async (model) => { + const query = "The following is the Codex agent history whose request action you are assessing."; + const messages = await read([ + ...prefix.slice(0, 2), event("turn_context", { turn_id: "turn-1", model }), + message("user", query), ...end + ]); + expect(sourceTurnFromMessages(messages)?.query).toBe(query); + }); + it("keeps only the person's own text when Codex packs injected context into a user message", async () => { const injected = event("response_item", { type: "message", diff --git a/Memory/src/service/import/memory-import-pipeline.ts b/Memory/src/service/import/memory-import-pipeline.ts index 29c74bc4c..b0920e9a7 100644 --- a/Memory/src/service/import/memory-import-pipeline.ts +++ b/Memory/src/service/import/memory-import-pipeline.ts @@ -3,7 +3,7 @@ import { captureTurnSteps, signatureFromTraceParts } from "../../algorithm/plugi import { MemoryServiceError } from "../../utils/error.js"; import { isoTimeToUtc } from "../../utils/time.js"; import { stableHash } from "../../utils/id.js"; -import { clip, firstLine, firstSemanticUserLine } from "../../utils/text.js"; +import { clip, firstLine, firstSemanticUserLine, sourceEnvelopePrefixLength } from "../../utils/text.js"; export const IMPORT_SUMMARY_QUEUED_TAG = "摘要排队中"; export const IMPORT_SUMMARY_PROCESSING_TAG = "摘要总结中"; @@ -142,8 +142,16 @@ function captureImportTraceTags(input: { } function parseMemoryAddSections(content: string): Array<{ role: "user" | "assistant" | "tool" | "system"; text: string }> { - const headingPattern = /^## (user|assistant|tool|system)\s*$/gm; - const headings = [...content.matchAll(headingPattern)]; + const headingPattern = /^## (user|assistant|tool|system)[ \t]*\r?$/gm; + let protectedUntil = sourceEnvelopePrefixLength(content); + const headings = [...content.matchAll(headingPattern)].filter((heading) => { + const start = heading.index! + heading[0].length; + if (heading.index! < protectedUntil) return false; + // Source payload can contain role headings; retain it in its original + // user section so all preview consumers see the complete envelope. + protectedUntil = start + sourceEnvelopePrefixLength(content.slice(start)); + return true; + }); return headings .map((heading, index) => { const nextHeading = headings[index + 1]; diff --git a/Memory/src/service/read-model/display-fields.ts b/Memory/src/service/read-model/display-fields.ts index 44864ac6e..11be9581e 100644 --- a/Memory/src/service/read-model/display-fields.ts +++ b/Memory/src/service/read-model/display-fields.ts @@ -1,10 +1,29 @@ import type { MemoryRow } from "../../types.js"; import { asStringArray, isRecord } from "../../utils/json.js"; +import { clip, semanticUserText } from "../../utils/text.js"; -const PLACEHOLDER_LINE = /^(user|assistant|system|tool|developer|摘要排队中|摘要整理中|建立索引中|索引建立中|索引已建立|反思生成中)$/i; +const PLACEHOLDER_LINE = /^(user|assistant|system|tool|developer|摘要排队中|摘要整理中|摘要总结中|建立索引中|索引建立中|索引已建立|反思生成中)$/i; const INTERNAL_LINE = /^(RawTurn|TraceStep|Alpha|Value|Priority|Reflection|Signature|Vec Summary|Vec Action|Trigger|Procedure|Verification|Boundary|Support|Gain|Raw gain|Confidence|Evidence):/i; const DRAFT_TITLE = /^Policy:\s+/i; +/** Undefined kind is the legacy L1 trace representation. */ +export function isTraceMemory(memory: MemoryRow): boolean { + return memory.memoryLayer === "L1" && (!memory.properties.internal_info.memory_kind || memory.properties.internal_info.memory_kind === "trace"); +} + +/** A missing stored summary must never be manufactured from source evidence. */ +export function storedTraceSummary(memory: MemoryRow): string | undefined { + const internal = memory.properties.internal_info; + const trace = isRecord(internal.trace) ? internal.trace : {}; + for (const value of [memory.info.summary, internal.summary, trace.summary]) { + if (typeof value !== "string") continue; + const cleaned = value.replace(/^\s*Summary:\s*/i, "").replace(/^\s*#{1,6}\s+/, "").trim(); + const first = cleaned.split(/\r?\n/).find(line => line.trim())?.trim(); + if (first && !PLACEHOLDER_LINE.test(first) && first !== "处理失败") return cleaned; + } + return undefined; +} + export function evidenceMemoryIds(memory: MemoryRow): string[] { const internal = memory.properties.internal_info; const policy = isRecord(internal.policy) ? internal.policy : {}; @@ -56,11 +75,7 @@ export function waitingSourceText(memory: MemoryRow, evidence: readonly MemoryRo const sources = memory.memoryLayer === "L2" ? evidence : [memory]; for (const source of sources) { const user = userSentence(source); - if (user) return user; - } - for (const source of sources) { - const line = readableLine(source.memoryValue); - if (line) return line; + if (user) return clip(user, 120); } return undefined; } @@ -84,30 +99,40 @@ export function displayFieldsForMemory( function userSentence(memory: MemoryRow): string | undefined { const trace = memory.properties.internal_info.trace; if (isRecord(trace)) { - const fromTrace = firstContentLine(text(trace.userText) ?? text(trace.user_text) ?? ""); - if (fromTrace) return fromTrace; + const userText = [trace.userText, trace.user_text].find((value): value is string => + typeof value === "string" && Boolean(value.trim())); + // A present but context-only query must not fall back to the same raw body. + if (userText) return firstContentLine(semanticUserText(userText)); } - let inUserSection = false; - for (const raw of memory.memoryValue.split(/\r?\n/)) { - const role = roleMarker(raw); - if (role) { - inUserSection = role === "user"; + // Also handle unstructured legacy bodies that start with an envelope; role + // headings inside that envelope are source data, not conversation boundaries. + const body = semanticUserText(memory.memoryValue.replace(/^\s*Summary:[ \t]*/i, "")); + const lines = body.split(/\r?\n/); + let hasRole = false; + let fence = ""; + for (const [index, raw] of lines.entries()) { + const marker = raw.match(/^ {0,3}(`{3,}|~{3,})/); + if (marker) { + if (!fence) fence = marker[1]!; + else if (marker[1]![0] === fence[0] && marker[1]!.length >= fence.length) fence = ""; continue; } - if (!inUserSection) continue; - const line = readableLine(raw); - if (line) return line; - } - return undefined; -} - -function readableLine(value: string): string | undefined { - for (const raw of value.split(/\r?\n/)) { - if (roleMarker(raw)) continue; - const line = firstContentLine(raw.replace(/^\s*Summary:\s*/i, "")); - if (line) return line; + if (fence) continue; + const role = roleMarker(raw); + if (!role) continue; + hasRole = true; + if (role === "user") { + // Strip envelopes before splitting role sections: browser context can + // itself contain headings such as "## assistant" or "## user". + const userLines = semanticUserText(lines.slice(index + 1).join("\n")).split(/\r?\n/); + const nextRole = userLines.findIndex((line) => Boolean(roleMarker(line))); + return firstContentLine((nextRole < 0 ? userLines : userLines.slice(0, nextRole)).join("\n")); + } } - return undefined; + if (hasRole) return undefined; + // Legacy unstructured memories have no role sections. Clean the whole value, + // not each line, so a rejected block cannot leak through the next line. + return firstContentLine(body); } function firstContentLine(value: string): string | undefined { diff --git a/Memory/src/service/read-model/memory.ts b/Memory/src/service/read-model/memory.ts index 248605ffa..02d808ec2 100644 --- a/Memory/src/service/read-model/memory.ts +++ b/Memory/src/service/read-model/memory.ts @@ -1,6 +1,6 @@ import type { MemoryDetailItem, MemoryProcessingRecord, MemoryRow } from "../../types.js"; import { kindFromMemory } from "../../storage/repositories.js"; -import { displayFieldsForMemory, observedActivitySummary } from "./display-fields.js"; +import { displayFieldsForMemory, isTraceMemory, observedActivitySummary, storedTraceSummary } from "./display-fields.js"; import { policyMetaFromMemory, skillMetaFromMemory, traceMetaFromMemory, worldModelMetaFromMemory } from "../../algorithm/plugin-algorithms.js"; import { panelSourceForMemory, panelTagsForMemory } from "./panel.js"; import { isRecord } from "../../utils/json.js"; @@ -35,6 +35,7 @@ export function detailTitleForMemory(memory: MemoryRow): string { } export function detailSummaryForMemory(memory: MemoryRow): string { + if (isTraceMemory(memory)) return storedTraceSummary(memory) ?? ""; const observationSummary = observedActivitySummary(memory); if (observationSummary) return observationSummary; if (memory.properties.internal_info.memory_kind === "work_memory") { diff --git a/Memory/src/service/read-model/panel-read.ts b/Memory/src/service/read-model/panel-read.ts index 92750c1f0..0e34ac1c6 100644 --- a/Memory/src/service/read-model/panel-read.ts +++ b/Memory/src/service/read-model/panel-read.ts @@ -246,7 +246,7 @@ export class PanelReadModel { const summary = memory && detail.role === "trace" ? detailSummaryForMemory(memory) : undefined; const key = detail.role === "span" ? "spanGoal" : "summary"; const value = spanGoal ?? summary; - if (!value || detail[key] === value) return detail; + if (value === undefined || (detail.role === "span" && !value) || detail[key] === value) return detail; changed = true; return { ...detail, [key]: value }; }); diff --git a/Memory/src/service/session/session-turn-service.ts b/Memory/src/service/session/session-turn-service.ts index ad745344b..b3a3ca929 100644 --- a/Memory/src/service/session/session-turn-service.ts +++ b/Memory/src/service/session/session-turn-service.ts @@ -83,7 +83,6 @@ import { } from "../namespace/namespace-scope.js"; import { detailSummaryForMemory, - detailTitleForMemory, firstDetailDisplayString } from "../read-model/memory.js"; import { @@ -2671,7 +2670,7 @@ export class SessionTurnService { episodeId: response.episodeId, query: request.query, agent: request.answer, - summary: memory ? detailSummaryForMemory(memory) || detailTitleForMemory(memory) : undefined + summary: memory ? detailSummaryForMemory(memory) : undefined }] }, Date.now() - startedAt, true, response.serverTime, memory?.agentId); } diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index 93cd63818..897936263 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -12,7 +12,7 @@ import { type WorkspaceUri } from "../contracts/index.js"; import { retrievalDocumentForMemory } from "../algorithm/plugin-algorithms.js"; -import { displayFieldsForMemory } from "../service/read-model/display-fields.js"; +import { displayFieldsForMemory, isTraceMemory, storedTraceSummary } from "../service/read-model/display-fields.js"; import type { ProjectEnvironmentKind, ProjectEnvironmentStateRecord @@ -41,7 +41,7 @@ import { DEFAULT_NAMESPACE_SOURCE } from "../types.js"; import { agentSourceFamilyRoots, normalizeAgentIdKey } from "../utils/agent-source-id.js"; import { newId, stableHash } from "../utils/id.js"; import { asStringArray, isRecord, parseJson, toJson } from "../utils/json.js"; -import { firstSemanticUserLine } from "../utils/text.js"; +import { firstSemanticUserLine, semanticUserText } from "../utils/text.js"; import { nowIso } from "../utils/time.js"; import { attachMemoryVectors, @@ -6961,6 +6961,7 @@ function listTitleForMemory(memory: MemoryRow): string { } function listSummaryForMemory(memory: MemoryRow): string { + if (isTraceMemory(memory)) return storedTraceSummary(memory) ?? ""; const internal = memory.properties.internal_info; if (internal.memory_kind === "work_memory" && typeof internal.requirement === "string") { return internal.requirement.trim(); @@ -7024,26 +7025,25 @@ function firstReadableMemoryValueLine(value: string): string | undefined { } function firstUserMemoryValueLine(value: string): string | undefined { - let inUserSection = false; - const userLines: string[] = []; - for (const line of value.split(/\r?\n/)) { - const role = memoryValueRoleMarker(line); - if (role) { - if (inUserSection && role !== "user") { - break; - } - inUserSection = role === "user"; - continue; - } - if (!inUserSection) { + const lines = semanticUserText(value).split(/\r?\n/); + let fence = ""; + for (const [index, line] of lines.entries()) { + const marker = line.match(/^ {0,3}(`{3,}|~{3,})/); + if (marker) { + if (!fence) fence = marker[1]!; + else if (marker[1]![0] === fence[0] && marker[1]!.length >= fence.length) fence = ""; continue; } - userLines.push(line); - } - const title = firstSemanticUserLine(userLines.join("\n")); - return title && !isPlaceholderMemorySummary(title) && !isWorldSectionHeading(title) && !isInternalMemoryKey(title) - ? title - : undefined; + if (fence) continue; + const role = memoryValueRoleMarker(line); + if (role !== "user") continue; + const userLines = semanticUserText(lines.slice(index + 1).join("\n")).split(/\r?\n/); + const nextRole = userLines.findIndex(line => Boolean(memoryValueRoleMarker(line))); + const title = firstSemanticUserLine((nextRole < 0 ? userLines : userLines.slice(0, nextRole)).join("\n")); + return title && !isPlaceholderMemorySummary(title) && !isWorldSectionHeading(title) && !isInternalMemoryKey(title) + ? title : undefined; + } + return undefined; } function isPlaceholderMemorySummary(value: string | undefined): boolean { diff --git a/Memory/src/utils/text.ts b/Memory/src/utils/text.ts index c5962d8f6..67f30cb9e 100644 --- a/Memory/src/utils/text.ts +++ b/Memory/src/utils/text.ts @@ -11,11 +11,95 @@ export function firstLine(value: string): string { } export function firstSemanticUserLine(value: string): string { - const userQuery = value.match(/\s*([\s\S]*?)\s*<\/user_query>/i)?.[1]; - const candidate = userQuery ?? value - .replace(/[\s\S]*?<\/timestamp>/gi, "\n") - .replace(/[\s\S]*?<\/system_notification>/gi, "\n") - .replace(/[\s\S]*?<\/system_reminder>/gi, "\n") - .replace(/[\s\S]*?<\/image_files>/gi, "\n"); - return firstLine(candidate.replace(/<\/?[a-z_][^>]*>/gi, "\n")); + return firstLine(semanticUserText(value)); +} + +// Only known source prefixes at the start of a message are protocol metadata. +const SOURCE_TAGS = "timestamp|system_notification|system_reminder|system-reminder|image_files|manually_attached_skills|attached_files|external_links|in-app-browser-context|pasted_content|chat_selection|user_query"; +const PREFIX_SPACE = String.raw`^(?:[ \t]*\r?\n)* {0,3}`; +const SOURCE_START = new RegExp(`${PREFIX_SPACE}<(${SOURCE_TAGS})(?=[\\s/>]|$)`, "i"); +const TAG_ATTRIBUTES = String.raw`(?:[^>"']|"[^"]*"|'[^']*')*>`; +const SOURCE_OPEN = new RegExp(`${PREFIX_SPACE}<(${SOURCE_TAGS})(?=[\\s/>])${TAG_ATTRIBUTES}`, "i"); + +type SourceBlock = { start: number; end: number; contentEnd: number; query: boolean }; + +function leadingSourceBlock(value: string): SourceBlock | undefined { + // A standalone image marker may be user prose. Only consume the marker when + // the next block is an explicit attachment or user-query protocol envelope. + const image = /^(?:[ \t]*\r?\n)* {0,3}\[Image\][ \t]*\r?\n(?=(?:[ \t]*\r?\n)* {0,3}<(?:image_files|attached_files|user_query)(?=[\s/>]))/.exec(value); + if (image) return { start: image[0].length, end: image[0].length, contentEnd: image[0].length, query: false }; + // chat_selection is the source's explicit fence label, not an arbitrary + // Markdown language. Other code fences and quoted examples remain literal. + const fence = /^(?:[ \t]*\r?\n)* {0,3}(`{3,}|~{3,})chat_selection[ \t]*\r?\n/.exec(value); + if (fence) { + const closing = new RegExp(`^ {0,3}${fence[1]![0]}{${fence[1]!.length},}[ \\t]*(?:\\r?\\n|$)`, "gm"); + closing.lastIndex = fence[0].length; + const end = closing.exec(value); + return { start: fence[0].length, end: end ? closing.lastIndex : value.length, contentEnd: end?.index ?? value.length, query: false }; + } + const opening = SOURCE_OPEN.exec(value); + if (!opening) { + return SOURCE_START.test(value) ? { start: value.length, end: value.length, contentEnd: value.length, query: false } : undefined; + } + const tag = opening[1]!.toLowerCase(); + if (/\/\s*>$/.test(opening[0])) return { start: opening[0].length, end: opening[0].length, contentEnd: opening[0].length, query: false }; + // Scan every markup token so a matching tag inside another tag's attribute + // or a comment cannot alter source nesting or the import role boundaries. + const tokens = /" : token[0].toUpperCase() === "" : undefined; + if (delimiter) { + const end = value.indexOf(delimiter, tokens.lastIndex); + if (end < 0) break; + tokens.lastIndex = end + delimiter.length; + continue; + } + let end = tokens.lastIndex; + let quote = ""; + for (; end < value.length; end += 1) { + const char = value[end]; + if (quote) { + if (char === quote) quote = ""; + } else if (char === '"' || char === "'") { + quote = char; + } else if (char === ">") { + break; + } + } + if (end === value.length) break; + tokens.lastIndex = end + 1; + if (token[2]?.toLowerCase() !== tag) continue; + if (token[1]) depth -= 1; + else if (!/\/\s*>$/.test(value.slice(token.index, tokens.lastIndex))) depth += 1; + if (depth === 0) { + return { start: opening[0].length, contentEnd: token.index, end: tokens.lastIndex, query: tag === "user_query" }; + } + } + // Missing closing boundaries must not expose payload as a user request. + return { start: value.length, contentEnd: value.length, end: value.length, query: false }; +} + +/** Protect role headings inside leading source blocks without changing raw text. */ +export function sourceEnvelopePrefixLength(value: string): number { + let offset = value.startsWith("\uFEFF") ? 1 : 0; + while (offset < value.length) { + const block = leadingSourceBlock(value.slice(offset)); + if (!block) break; + offset += block.end; + } + return offset; +} + +/** Remove whole injected blocks before selecting a line, without changing stored text. */ +export function semanticUserText(value: string): string { + let remaining = value.replace(/^\uFEFF/, ""); + while (remaining) { + const block = leadingSourceBlock(remaining); + if (!block) return remaining; + remaining = block.query ? remaining.slice(block.start, block.contentEnd) : remaining.slice(block.end); + } + return ""; } diff --git a/Memory/tests/runtime-installer.test.ts b/Memory/tests/runtime-installer.test.ts index 8f0f33451..d2f0b411c 100644 --- a/Memory/tests/runtime-installer.test.ts +++ b/Memory/tests/runtime-installer.test.ts @@ -174,7 +174,7 @@ describe("standalone Memory runtime installer", () => { finally { fetchSpy.mockRestore(); } } expect(existsSync(join(home, "memory-service", "install.lock"))).toBe(false); - }); + }, 20_000); it.each([true, false])("replaces changed same-version builds (preferInstalledCompatible=%s) without touching data", async (preferInstalledCompatible) => { const root = tempRoot(); @@ -197,7 +197,7 @@ describe("standalone Memory runtime installer", () => { expect(changed.entrypoint).not.toBe(upgraded.entrypoint); const reused = await installMemoryRuntime({ ...flags, runtimeDirectory: next, preferInstalledCompatible: true }); expect(reused).toMatchObject({ reused: true, buildId: "b".repeat(64) }); - }); + }, 20_000); it("keeps the previous pointer after a changed same-version build fails activation", async () => { const root = tempRoot(); diff --git a/Memory/tests/service/import/import-processing.test.ts b/Memory/tests/service/import/import-processing.test.ts index 47062810c..6d25e22c6 100644 --- a/Memory/tests/service/import/import-processing.test.ts +++ b/Memory/tests/service/import/import-processing.test.ts @@ -320,6 +320,7 @@ describe("MemoryService / import / processing", () => { }); expect(added.title).toBe("修复 Cursor 记忆标题。"); + expect(service.panelItems({ layer: "L1" }).items.find(item => item.id === added.id)?.sourceText).toBe("修复 Cursor 记忆标题。"); const notification = service.addMemory({ adapterId: "agent-source:cursor", @@ -342,9 +343,35 @@ describe("MemoryService / import / processing", () => { }); expect(notification.title).toBe("cursor conversation"); + expect(service.panelItems({ layer: "L1" }).items.find(item => item.id === notification.id)?.sourceText).toBeUndefined(); db.close(); }); + it.each([ + '技能示例\ntoday\n修复标题', + '附件\n修复标题', + '\uFEFFtoday\n链接\n修复标题', + '提醒\n修复标题' + ])("persists a semantic import title and preview for source envelopes: %s", (userText) => { + const { db, service } = createTestService(); + try { + const added = service.addMemory({ + adapterId: "agent-source:cursor", requestId: "cursor-envelope-regression", + layer: "L1", source: "cursor", tags: ["agent-source", "cursor"], + title: "", turnId: "cursor:envelope-regression", + content: `## user\n\n${userText}\n\n## assistant\n\n已修复。` + }); + expect(added.title).toBe("修复标题"); + const stored = db.db.prepare("SELECT info_json, properties_json FROM memories WHERE id = ?").get(added.id) as { info_json: string; properties_json: string }; + expect(JSON.parse(stored.info_json).title).toBe("修复标题"); + // Import section parsing already trims leading whitespace, including BOM. + expect(JSON.parse(stored.properties_json).internal_info.trace.userText).toBe(userText.trim()); + expect(service.panelItems({ layer: "L1" }).items.find(item => item.id === added.id)?.sourceText).toBe("修复标题"); + } finally { + db.close(); + } + }); + it("uses the standard summary prompt for account summaries", async () => { const root = createTestRoot("mindock-memory-account-summary-"); const db = new MemoryDb({ diff --git a/Memory/tests/service/read-model/display-fields.test.ts b/Memory/tests/service/read-model/display-fields.test.ts new file mode 100644 index 000000000..5d608a90a --- /dev/null +++ b/Memory/tests/service/read-model/display-fields.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import type { MemoryRow } from "../../../src/types.js"; +import { displayFieldsForMemory, waitingSourceText } from "../../../src/service/read-model/display-fields.js"; +import { firstSemanticUserLine, sourceEnvelopePrefixLength } from "../../../src/utils/text.js"; + +const request = "请修复扫描后的记忆标题"; +const browser = '\n页面正文\n页面内的引用\n'; + +function memory(userText?: string, body = ""): MemoryRow { + return { + id: "trace_preview", timeline: "2026-09-10T13:00:00Z", userId: "user-preview", + memoryType: "LongTermMemory", status: "activated", visibility: "private", + memoryValue: body, tags: [], info: {}, + properties: { internal_info: { memory_layer: "L1", memory_kind: "trace", trace: { userText } } }, + memoryLayer: "L1", version: 1, createdAt: "2026-09-10T13:00:00Z", updatedAt: "2026-09-10T13:00:00Z" + }; +} + +describe("waiting memory previews", () => { + it.each([ + '
    quoted
    ', + "
    quoted
    ", + '
    quoted
    ', + 'quoted', + 'quoted', + 'quoted
    ]]>', + '">', + 'nested', + 'nested sibling', + 'comparison: x < y and x > y' + ])("ignores quoted tag text while preserving real source nesting: %s", (payload) => { + const envelope = `\n${payload}\n`; + const input = `${envelope}\n${request}`; + expect(firstSemanticUserLine(input)).toBe(request); + expect(waitingSourceText(memory(input))).toBe(request); + expect(sourceEnvelopePrefixLength(input)).toBe(envelope.length); + }); + + it.each([ + '
    { + const input = `\n${payload}\n\n${request}`; + expect(firstSemanticUserLine(input)).toBe(""); + expect(sourceEnvelopePrefixLength(input)).toBe(input.length); + }); + + it.each([ + `Tuesday, Jun 16, 2026, 10:16 PM (UTC+8)\n${request}`, + `${browser}\n${request}`, + `\uFEFFtoday\n${request}`, + `\n附件内容\n\n${request}`, + `today\n链接\n${request}`, + `提醒\n${request}`, + `\n技能中的示例请求\n\ntoday\n${request}`, + `today\n${request}\n`, + `outerinnertail\n${request}`, + `status\n${request}`, + `\ntoday\n${request}`, + `正文\n${request}`, + `正文\n${request}`, + `outerinnertail\n${request}`, + `正文\n附件\n${request}`, + `[Image]\n附件\n${request}`, + `引用的对话\n${request}`, + `\`\`\`chat_selection\n引用的对话\n\`\`\`\n${request}`, + `~~~~chat_selection\n引用的对话\n~~~~\n${request}` + ])("extracts the same request for import titles and waiting previews: %s", (input) => { + expect(firstSemanticUserLine(input)).toBe(request); + const row = memory(input, `User:\n${input}\nAssistant:\n不要把回复当标题`); + const before = JSON.stringify(row); + expect(waitingSourceText(row)).toBe(request); + expect(JSON.stringify(row)).toBe(before); + }); + + it.each([ + browser, + '未闭合附件\n不能提取附件内请求', + '技能内容', + '\n未闭合页面正文', + 'today\ndone", + "未闭合请求", + "摘要总结中", + '正文', + '未闭合\n嵌入的请求', + '只有引用' + ])("does not recover rejected context from the raw body: %s", (input) => { + expect(waitingSourceText(memory(input, `User:\n${input}\nAssistant:\n不要把回复当标题`))).toBeUndefined(); + expect(waitingSourceText(memory(undefined, `User:\n${input}\nAssistant:\n不要把回复当标题`))).toBeUndefined(); + }); + + it.each([ + '
    请修改这个元素
    ', + '
    普通 HTML
    \n字面量', + '自定义元素\n字面量', + '> 引用内容\n字面量', + '```xml\n代码\n字面量\n```', + "解释 和 x < y 的含义", + "> quoted example", + "```xml\ncode example\n```", + " indented code", + "The following is the Codex agent history whose request action you are assessing.", + "[Image]\n这是普通文字,没有附件协议", + "请解释下面的标签\n示例", + '自定义标签', + '> 引用示例', + '```xml\n代码示例\n```', + ' ```chat_selection\n 普通缩进代码\n ```', + '```typescript\n// chat_selection\n```' + ])("preserves ordinary HTML, code, quotes and unverified review text: %s", (input) => { + expect(firstSemanticUserLine(input)).toBe(input.split("\n")[0]!.trim()); + expect(waitingSourceText(memory(input))).toBe(input.split("\n")[0]!.trim()); + }); + + it("handles legacy user_text and complete User sections without using assistant content", () => { + const row = memory(); + row.properties.internal_info.trace = { user_text: `${browser}\n${request}` }; + expect(waitingSourceText(row)).toBe(request); + expect(waitingSourceText(memory(undefined, `## user\n${browser}\n${request}\n## assistant\nDone`))).toBe(request); + expect(waitingSourceText(memory(undefined, "Assistant:\nNot a user request"))).toBeUndefined(); + expect(waitingSourceText(memory(undefined, `Summary: ${browser}`))).toBeUndefined(); + expect(waitingSourceText(memory(undefined, "Summary: 旧版可读正文\nRawTurn: raw_id"))).toBe("旧版可读正文"); + const nestedRoles = '\n## assistant\n页面中的回复\n## user\n页面中的请求\n'; + expect(waitingSourceText(memory(undefined, `User:\n${nestedRoles}\n${request}\nAssistant:\nDone`))).toBe(request); + expect(waitingSourceText(memory(undefined, `User:\n${nestedRoles}\nAssistant:\nDone`))).toBeUndefined(); + expect(waitingSourceText(memory(undefined, `Summary: ${nestedRoles}`))).toBeUndefined(); + expect(waitingSourceText(memory(undefined, `Summary: ${nestedRoles}\n${request}`))).toBe(request); + expect(waitingSourceText(memory(undefined, "```text\nUser:\n示例内容\n```"))).toBe("```text"); + }); + + it("skips empty L2 evidence and keeps generated titles independent of previews", () => { + const policy = { ...memory(), memoryLayer: "L2" as const }; + expect(waitingSourceText(policy, [memory(browser), memory(`${browser}\n${request}`)])).toBe(request); + expect(waitingSourceText(policy, [memory(browser)])).toBeUndefined(); + const row = memory(`${browser}\n${request}`); + row.properties.internal_info.trace = { userText: `${browser}\n${request}`, title: "扫描标题修复" }; + expect(displayFieldsForMemory(row)).toEqual({ sourceText: request, generatedTitle: "扫描标题修复" }); + }); + + it("filters before clipping so long context never displaces the real request", () => { + const context = `\n${"页面正文\n".repeat(10_000)}`; + expect(waitingSourceText(memory(`${context}\n${request}`))).toBe(request); + const longRequest = request.repeat(30); + const preview = waitingSourceText(memory(`${context}\n${longRequest}`))!; + expect(preview).toHaveLength(120); + expect(preview).toBe(`${longRequest.slice(0, 117)}...`); + }); +}); diff --git a/Memory/tests/service/read-model/panel-read.test.ts b/Memory/tests/service/read-model/panel-read.test.ts index ffd24e7b4..e5e350ff6 100644 --- a/Memory/tests/service/read-model/panel-read.test.ts +++ b/Memory/tests/service/read-model/panel-read.test.ts @@ -348,6 +348,32 @@ describe("MemoryService / read model / panel", () => { db.close(); }); + it("cleans old imported context on panel/detail reads without rewriting stored content", () => { + const { db, service } = createTestService(); + const repos = new Repositories(db.db); + const namespace = { source: "codex", profileId: "preview", userId: "user-preview" }; + const userText = '\n页面内容\n\n请修复扫描标题'; + const added = addAgentSourceImport(service, namespace, userText, "preview-context"); + const row = repos.memories.get(added.id)!; + // Simulate a record imported before this repair; reads must not depend on + // re-importing it or rewriting its stored provisional title. + row.info.title = ''; + repos.memories.update(row); + const before = repos.memories.get(added.id)!; + const listed = service.panelItems({ namespace, layer: "L1" }).items.find(item => item.id === added.id); + expect(listed?.sourceText).toBe("请修复扫描标题"); + expect(listed?.processing?.state).toBe("summary_pending"); + expect(listed?.generatedTitle).toBeUndefined(); + expect(detailFromMemory(before).sourceText).toBe("请修复扫描标题"); + expect(repos.memories.get(added.id)).toEqual(before); + + const contextOnly = addAgentSourceImport(service, namespace, '\n页面内容\n', "preview-context-only"); + const empty = service.panelItems({ namespace, layer: "L1" }).items.find(item => item.id === contextOnly.id); + expect(empty?.sourceText).toBeUndefined(); + expect(detailFromMemory(repos.memories.get(contextOnly.id)!).sourceText).toBeUndefined(); + db.close(); + }); + it("does not treat an imported user-sentence title as a generated title", () => { const { db, service } = createTestService(); const repos = new Repositories(db.db); diff --git a/Memory/tests/service/session/turn-capture.test.ts b/Memory/tests/service/session/turn-capture.test.ts index 3bbfc1ddd..020f2eead 100644 --- a/Memory/tests/service/session/turn-capture.test.ts +++ b/Memory/tests/service/session/turn-capture.test.ts @@ -650,7 +650,8 @@ describe("MemoryService / session / turn capture", () => { agent: "The command completed." }) ]); - expect(memoryAddOutput.details[0]?.summary).toMatch(/^RawTurn:/); + // No summary has been generated yet; internal body markers are not summaries. + expect(memoryAddOutput.details[0]?.summary).toBe(""); db.close(); }); diff --git a/package-lock.json b/package-lock.json index 1711bc31e..b27f12938 100644 --- a/package-lock.json +++ b/package-lock.json @@ -581,7 +581,8 @@ "devDependencies": { "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", - "happy-dom": "^20.10.6" + "happy-dom": "^20.10.6", + "@types/hast": "^3.0.4" } }, "App/shell/desktop": {