Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/core/prompts/l1-dedup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const CONFLICT_DETECTION_SYSTEM_PROMPT = `你是记忆冲突检测器。
- target_ids:要删除替换的旧记忆 ID **数组**(可以 1 条或多条)。store/skip 时省略或为空。
- merged_content:merge/update 时的最终记忆文本。store/skip 时省略。
- merged_type:merge/update 后记忆应归属的 type。根据合并后内容本质判断。
- merged_priority:merge/update 后的新优先级(0-100 整数,merge/update 时必填)。合并后信息更完整、更确定,通常应**酌情提升** priority(例如两条 priority 70 的记忆合并后可提升到 80)。参考标准:80-100(核心特质/重要事件),60-79(一般偏好/普通活动),<60(次要信息)。
- merged_priority:merge/update 后的新优先级(0-100 整数,merge/update 时必填)。不要因为合并本身提高优先级;merged_priority 必须小于或等于参与合并/更新的新记忆与 target_ids 对应旧记忆中的最高 priority。参考标准:80-100(核心特质/重要事件),60-79(一般偏好/普通活动),<60(次要信息)。
- merged_timestamps:合并后的时间戳数组。收集新记忆 + 所有被合并旧记忆的时间戳,去重排序。`;

// ============================
Expand Down
68 changes: 68 additions & 0 deletions src/core/record/l1-dedup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";

import { batchDedup } from "./l1-dedup.js";
import type { ExtractedMemory } from "./l1-writer.js";
import type { IMemoryStore, L1FtsResult } from "../store/types.js";
import type { LLMRunner } from "../types.js";

describe("batchDedup", () => {
it("caps merged priority at the strongest source memory priority", async () => {
const newMemory: ExtractedMemory & { record_id: string } = {
record_id: "new_1",
content: "User asked the agent to only reply OK during diagnostic testing.",
type: "instruction",
priority: 75,
source_message_ids: ["msg_1"],
metadata: {},
scene_name: "diagnostic testing",
};

const candidate: L1FtsResult = {
record_id: "old_1",
content: "User asked the assistant not to sound cold during a style adjustment.",
type: "instruction",
priority: 80,
scene_name: "style adjustment",
score: 1,
timestamp_str: "2026-05-25T08:28:48.298Z",
timestamp_start: "2026-05-25T08:28:48.298Z",
timestamp_end: "2026-05-25T08:28:48.298Z",
session_key: "session-a",
session_id: "thread-a",
metadata_json: "{}",
};

const vectorStore = {
countL1: () => 1,
isFtsAvailable: () => true,
searchL1Fts: async () => [candidate],
} as unknown as IMemoryStore;

const llmRunner: LLMRunner = {
async run() {
return JSON.stringify([
{
record_id: "new_1",
action: "merge",
target_ids: ["old_1"],
merged_content: "User wants extremely concise assistant replies and OK-style confirmations.",
merged_type: "instruction",
merged_priority: 95,
merged_timestamps: ["2026-05-25T08:28:48.298Z"],
},
]);
},
};

const decisions = await batchDedup({
memories: [newMemory],
config: {},
vectorStore,
llmRunner,
});

expect(decisions).toHaveLength(1);
expect(decisions[0].action).toBe("merge");
expect(decisions[0].merged_priority).toBe(80);
});
});
49 changes: 48 additions & 1 deletion src/core/record/l1-dedup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ async function runLlmJudgment(
}

const decisions = parseBatchResult(result, memories, logger);
return decisions;
return capMergedPriorities(decisions, matches, logger);
} catch (err) {
logger?.warn?.(
`${TAG} Batch conflict detection failed, defaulting all to store: ${err instanceof Error ? err.message : String(err)}`,
Expand Down Expand Up @@ -386,6 +386,53 @@ function parseBatchResult(
}
}

/**
* Deterministic guardrail: merge/update can combine evidence, but must not
* amplify priority beyond the strongest source memory involved.
*/
function capMergedPriorities(
decisions: DedupDecision[],
matches: CandidateMatch[],
logger?: Logger,
): DedupDecision[] {
const matchByRecordId = new Map(matches.map((m) => [m.newMemory.record_id, m]));

return decisions.map((decision) => {
if (
(decision.action !== "merge" && decision.action !== "update") ||
typeof decision.merged_priority !== "number"
) {
return decision;
}

const match = matchByRecordId.get(decision.record_id);
if (!match) return decision;

const targetIds = new Set(decision.target_ids);
const sourcePriorities = [
match.newMemory.priority,
...match.candidates
.filter((candidate) => targetIds.has(candidate.id))
.map((candidate) => candidate.priority),
].filter((priority) => Number.isFinite(priority));

const maxSourcePriority = Math.max(...sourcePriorities);
if (!Number.isFinite(maxSourcePriority) || decision.merged_priority <= maxSourcePriority) {
return decision;
}

logger?.debug?.(
`${TAG} Capping merged priority for ${decision.record_id}: ` +
`${decision.merged_priority} → ${maxSourcePriority}`,
);

return {
...decision,
merged_priority: maxSourcePriority,
};
});
}

/**
* Fallback: store all memories when parsing fails.
*/
Expand Down
Loading