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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Memory/src/service/embedding/embedding-job-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
buildUserMemory,
isDynamicCurrentFactQuery
} from "../user-memory/user-memory.js";
import { fallbackCaptureSummary } from "../evolution/capture-summary.js";
import {
embeddingTextForMemory,
traceSummaryEmbeddingText,
Expand Down Expand Up @@ -841,7 +842,11 @@ function fallbackImportSummary(trace: TraceMeta, memory: MemoryRow): string {
}

function fallbackTraceSummary(trace: TraceMeta): string {
return clip(firstLine([trace.summary, trace.userText, trace.agentText].filter(Boolean).join("\n")) || "trace memory", 200);
return fallbackCaptureSummary({
userText: trace.userText,
agentText: trace.agentText,
toolCalls: trace.toolCalls
});
}

function renderTraceMemoryValue(step: { summary: string; rawTurnId?: string; stepIndex?: number; userText?: string; agentText?: string; toolCalls: Array<{ name: string; input?: unknown; output?: unknown; error?: string }>; reflection: { text: string | null; alpha: number }; value: number; priority: number }): string {
Expand Down
139 changes: 139 additions & 0 deletions Memory/src/service/evolution/capture-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { ToolCallPayload } from "../../types.js";
import { stringifyForMemory } from "../../utils/json.js";
import { clip, firstLine } from "../../utils/text.js";

const ATTACHMENT_READ_INSTRUCTION =
"请用 read_file 工具按需读取上述附件;PDF 可用 pages 参数分页读取。";
const ATTACHMENT_BLOCK_PATTERN =
/^\s*<attachments>\s*\r?\n([\s\S]*?)\r?\n<\/attachments>(?:\s*\r?\n)*/i;
const ATTACHMENT_LINE_PATTERN =
/^-\s+(.+?)\s{2,}\(([^,\r\n]+),\s*([^)]+)\)\s{2,}(.+)$/;
const MAX_ATTACHMENT_METADATA_ITEMS = 5;

export interface CaptureSummarySource {
userText: string;
agentText: string;
toolCalls: ToolCallPayload[];
}

export interface NormalizedCaptureSummaryUserText {
requestText: string;
attachmentMetadata: string[];
recognizedAttachmentManifest: boolean;
}

/**
* Removes only the attachment envelope emitted by Memmy's agent runtime.
* User-authored XML is preserved unless it has the exact generated manifest shape.
*/
export function normalizeCaptureSummaryUserText(
value: string
): NormalizedCaptureSummaryUserText {
const match = value.match(ATTACHMENT_BLOCK_PATTERN);
if (!match) {
return {
requestText: value.trim(),
attachmentMetadata: [],
recognizedAttachmentManifest: false
};
}

const lines = (match[1] ?? "").split(/\r?\n/);
const instructionIndex = lines.findIndex(
(line) => line.trim() === ATTACHMENT_READ_INSTRUCTION
);
const manifestLines = instructionIndex < 0
? []
: lines.slice(0, instructionIndex).map((line) => line.trim()).filter(Boolean);
const attachments = manifestLines.map(parseAttachmentManifestLine);
if (
instructionIndex < 0 ||
attachments.length === 0 ||
attachments.some((attachment) => attachment === null)
) {
return {
requestText: value.trim(),
attachmentMetadata: [],
recognizedAttachmentManifest: false
};
}

const visibleAttachments = attachments
.slice(0, MAX_ATTACHMENT_METADATA_ITEMS)
.filter((attachment): attachment is string => Boolean(attachment));
if (attachments.length > MAX_ATTACHMENT_METADATA_ITEMS) {
visibleAttachments.push(`以及 ${attachments.length - MAX_ATTACHMENT_METADATA_ITEMS} 个其他附件`);
}

return {
requestText: value.slice(match[0].length).trim(),
attachmentMetadata: visibleAttachments,
recognizedAttachmentManifest: true
};
}

/**
* Validates a model-produced capture summary and falls back to semantic turn content.
*/
export function sanitizeCaptureSummary(
value: string,
source: CaptureSummarySource
): string {
const cleaned = sanitizeSummaryText(value)
.replace(/<\/?attachments>/gi, "")
.trim();
return isUnusableCaptureSummary(cleaned)
? fallbackCaptureSummary(source)
: cleaned;
}

/**
* Produces a deterministic semantic summary without using internal attachment markup.
*/
export function fallbackCaptureSummary(source: CaptureSummarySource): string {
const normalizedUser = normalizeCaptureSummaryUserText(source.userText);
const toolOutputs = [...source.toolCalls]
.reverse()
.map((call) => stringifyForMemory(call.output));
const candidates = normalizedUser.recognizedAttachmentManifest
? [source.agentText, ...toolOutputs, normalizedUser.requestText]
: [normalizedUser.requestText, source.agentText, ...toolOutputs];
for (const candidate of candidates) {
const summary = firstUsableSummaryLine(candidate);
if (summary) return clip(summary, 200);
}
const attachmentName = normalizedUser.attachmentMetadata[0];
return attachmentName ? clip(`已处理附件:${attachmentName}`, 200) : "trace memory";
}

function parseAttachmentManifestLine(value: string): string | null {
const match = value.match(ATTACHMENT_LINE_PATTERN);
if (!match) return null;
const [, fileName, mime, size] = match;
return `${fileName?.trim()} (${mime?.trim()}, ${size?.trim()})`;
}

function firstUsableSummaryLine(value: string): string {
const line = sanitizeSummaryText(firstLine(value))
.replace(/^\s*#{1,6}\s+/, "")
.replace(/^\s*[-*]\s+/, "")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.trim();
return isUnusableCaptureSummary(line) ? "" : line;
}

function sanitizeSummaryText(value: string): string {
return value
.replace(/^```(?:json|text|markdown)?/i, "")
.replace(/```$/i, "")
.replace(/\s+/g, " ")
.trim();
}

function isUnusableCaptureSummary(value: string): boolean {
const normalized = value.trim();
if (!normalized) return true;
if (normalized.includes(ATTACHMENT_READ_INSTRUCTION)) return true;
if (/^(?:<\/?[a-z_][^>]*>\s*)+$/i.test(normalized)) return true;
return /^(?:附件|文件|上传的附件|attachment|attachments|file|uploaded file)$/i.test(normalized);
}
34 changes: 21 additions & 13 deletions Memory/src/service/evolution/span-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import {
} from "../import/import-job-processor.js";
import { summarizeTurn as sessionSummarizeTurn } from "../session/session-turn-service.js";
import type { EnqueueJobInput } from "../worker/job-handlers.js";
import {
normalizeCaptureSummaryUserText,
sanitizeCaptureSummary
} from "./capture-summary.js";

type TraceMeta = NonNullable<ReturnType<typeof traceMetaFromMemory>>;

Expand Down Expand Up @@ -681,8 +685,7 @@ private reflectionDownstreamPreview(job: EvolutionJobRecord, memory: MemoryRow):
temperature: 0,
maxTokens: MEMORY_SUMMARY_MAX_TOKENS
});
const summary = sanitizeSummaryText(stringOr(result.summary, ""));
return summary || input.trace.summary;
return sanitizeCaptureSummary(stringOr(result.summary, ""), input);
};

try {
Expand Down Expand Up @@ -759,7 +762,9 @@ private reflectionDownstreamPreview(job: EvolutionJobRecord, memory: MemoryRow):
}
const l1 = isRecord(result.l1) ? result.l1 : undefined;
const user = isRecord(result.user) ? result.user : undefined;
const l1Summary = sanitizeSummaryText(stringOr(l1?.summary, ""));
const l1Summary = l1
? sanitizeCaptureSummary(stringOr(l1.summary, ""), input)
: "";
if (l1 && !l1Summary) {
throw new Error("turn memory decision requires l1.summary when l1 is not null");
}
Expand Down Expand Up @@ -999,6 +1004,8 @@ Rules:
- For images, files, or search results, preserve image captions, visible text,
retrieval queries, topics, and answer-relevant evidence; omit raw URLs unless
the URL itself is important.
- ATTACHMENT METADATA is internal context. Never use an attachment wrapper,
generic "attachment/file" wording, or metadata alone as the summary.
- Preserve original speaker/person names. User/assistant roles may be import
roles and must not replace real participants when names are present.
- Do not invent facts. Do not infer ownership from neighboring turns.
Expand All @@ -1023,6 +1030,8 @@ L1 — apply in order; earlier rules override later exclusions.
3. Also create for reusable work constraints, decisions, verified tool results, durable project facts, or task feedback.
4. Otherwise do not create for questions, acknowledgements, social chat, recalled answers, ordinary personal facts/preferences, or volatile facts.
A durable Agent work convention marked by 以后/每次/始终/always MUST create both L1 and User Memory. Keep summary grounded, in USER language, <=200 characters.
ATTACHMENT METADATA is internal context. Never summarize its wrapper or generic
attachment/file wording; summarize the actual request, content, or outcome.

OUTPUT
- Use null when that memory is not created. Every evidence quote must be a non-empty exact substring of its source.
Expand Down Expand Up @@ -1254,14 +1263,6 @@ function sanitizeReflectionText(value: string): string {
.trim();
}

function sanitizeSummaryText(value: string): string {
return value
.replace(/^```(?:json|text|markdown)?/i, "")
.replace(/```$/i, "")
.replace(/\s+/g, " ")
.trim();
}

function reflectionContextIncludesDownstream(mode: string): boolean {
return mode === "downstream" || mode === "task_downstream";
}
Expand Down Expand Up @@ -1382,8 +1383,9 @@ function traceSummaryPayload(input: {
reflectionText: string;
}, includeToolOutput = false): string {
const parts: string[] = [`CAPTURED AT: ${formatZonedTime(input.trace.ts, input.trace.timeZone)}`];
if (input.userText) {
parts.push(`USER:\n${clip(input.userText, 1400)}`);
const normalizedUser = normalizeCaptureSummaryUserText(input.userText);
if (normalizedUser.requestText) {
parts.push(`USER:\n${clip(normalizedUser.requestText, 1400)}`);
}
if (input.agentText) {
parts.push(`ASSISTANT:\n${clip(input.agentText, 1400)}`);
Expand All @@ -1398,6 +1400,12 @@ function traceSummaryPayload(input: {
if (input.reflectionText) {
parts.push(`REFLECTION:\n${clip(input.reflectionText, 300)}`);
}
if (normalizedUser.attachmentMetadata.length > 0) {
parts.push([
"ATTACHMENT METADATA (internal context; do not quote as summary/evidence):",
clip(normalizedUser.attachmentMetadata.map((item) => `- ${item}`).join("\n"), 400)
].join("\n"));
}
return clip(parts.join("\n\n"), includeToolOutput ? 5_000 : 3_500);
}

Expand Down
49 changes: 49 additions & 0 deletions Memory/tests/service/evolution/capture-summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
fallbackCaptureSummary,
normalizeCaptureSummaryUserText,
sanitizeCaptureSummary
} from "../../../src/service/evolution/capture-summary.js";

const generatedAttachmentTurn = [
"<attachments>",
"- hypre.txt (application/octet-stream, 15.4MB) /Users/test/.memmy/media/hypre.txt",
"",
"请用 read_file 工具按需读取上述附件;PDF 可用 pages 参数分页读取。",
"</attachments>",
"",
"这个文件的内容是什么?"
].join("\n");

describe("capture summary attachment normalization", () => {
it("moves the real request ahead of bounded attachment metadata", () => {
expect(normalizeCaptureSummaryUserText(generatedAttachmentTurn)).toEqual({
requestText: "这个文件的内容是什么?",
attachmentMetadata: ["hypre.txt (application/octet-stream, 15.4MB)"],
recognizedAttachmentManifest: true
});
});

it("does not strip user-authored attachment XML without Memmy's manifest shape", () => {
const userText = "<attachments>\n这是用户正在讨论的 XML 示例。\n</attachments>";

expect(normalizeCaptureSummaryUserText(userText)).toEqual({
requestText: userText,
attachmentMetadata: [],
recognizedAttachmentManifest: false
});
});

it("replaces wrapper-only model summaries with the assistant's semantic result", () => {
const source = {
userText: generatedAttachmentTurn,
agentText: "这个附件是一个 **HYPRE 2.31.0 源码集合**,包含并行求解器实现。",
toolCalls: [{ name: "read_file", output: "set(HYPRE_VERSION 2.31.0)" }]
};

expect(sanitizeCaptureSummary("<attachments>", source)).toBe(
"这个附件是一个 HYPRE 2.31.0 源码集合,包含并行求解器实现。"
);
expect(fallbackCaptureSummary(source)).not.toContain("<attachments>");
});
});
63 changes: 62 additions & 1 deletion Memory/tests/service/session/turn-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {
MemoryDb
} from "../../../src/index.js";
import { captureTurnSteps } from "../../../src/algorithm/plugin-algorithms.js";
import { createMemoryServiceFixture } from "../../fixtures/memory-service-fixture.js";
import {
createBatchReflectionLlm,
createMemoryServiceFixture
} from "../../fixtures/memory-service-fixture.js";

const {
cleanup,
Expand Down Expand Up @@ -765,6 +768,64 @@ describe("MemoryService / session / turn capture", () => {
db.close();
});

it("keeps raw attachment turns but sends normalized input to capture summary", async () => {
const calls: Array<{
messages: Array<{ role: string; content: string }>;
options: { operation: string; thinkingMode?: "inherit" | "enabled" | "disabled" };
}> = [];
const llm = createBatchReflectionLlm(calls, "<attachments>", "attachment-summary");
const { db, service } = createTestService({ llm });
const session = service.openSession({
namespace: {
source: "memmy-agent",
profileId: "default",
userId: "attachment-summary-user"
}
});
const query = [
"<attachments>",
"- hypre.txt (application/octet-stream, 15.4MB) /Users/test/.memmy/media/hypre.txt",
"",
"请用 read_file 工具按需读取上述附件;PDF 可用 pages 参数分页读取。",
"</attachments>",
"",
"这个文件的内容是什么?"
].join("\n");
const complete = service.completeTurn("turn-attachment-summary", {
sessionId: session.sessionId,
query,
answer: "这个附件是一个 **HYPRE 2.31.0 源码集合**,包含并行求解器实现。",
toolCalls: [{
name: "read_file",
input: { path: "/Users/test/.memmy/media/hypre.txt" },
output: "set(HYPRE_VERSION 2.31.0)",
success: true
}]
});

await service.runWorkerOnce(20);

const summaryCall = calls.find((call) => call.options.operation === "capture.summarize");
const payload = summaryCall?.messages.find((message) => message.role === "user")?.content ?? "";
expect(payload).toContain("USER: 这个文件的内容是什么?");
expect(payload).not.toContain("USER: <attachments>");
expect(payload).not.toContain("请用 read_file 工具按需读取上述附件");
expect(payload).toContain(
"ATTACHMENT METADATA (internal context; do not quote as summary/evidence): - hypre.txt (application/octet-stream, 15.4MB)"
);
expect(payload.split("ATTACHMENT METADATA")[1]).not.toContain("/Users/test");

const rawTurn = db.db.prepare(
"SELECT user_text FROM raw_turns WHERE id = ?"
).get(complete.rawTurnId) as { user_text: string };
expect(rawTurn.user_text).toBe(query);
expect(service.getMemory(complete.l1MemoryId).item.summary).toBe(
"这个附件是一个 HYPRE 2.31.0 源码集合,包含并行求解器实现。"
);

db.close();
});

it("does not store empty turns", () => {
const { db, service } = createTestService();
const session = service.openSession({
Expand Down
Loading