Implement scoped memory graph and refresh defaults

This commit is contained in:
Youzini-afk
2026-04-03 20:48:07 +08:00
parent fbd8b00f1f
commit c60f60f349
21 changed files with 1706 additions and 352 deletions

View File

@@ -66,6 +66,16 @@ assert.equal(defaultSettings.recallNmfTopics, 15);
assert.equal(defaultSettings.recallNmfNoveltyThreshold, 0.4);
assert.equal(defaultSettings.recallResidualThreshold, 0.3);
assert.equal(defaultSettings.recallResidualTopK, 5);
assert.equal(defaultSettings.enableScopedMemory, true);
assert.equal(defaultSettings.enablePovMemory, true);
assert.equal(defaultSettings.enableRegionScopedObjective, true);
assert.equal(defaultSettings.recallCharacterPovWeight, 1.25);
assert.equal(defaultSettings.recallUserPovWeight, 1.05);
assert.equal(defaultSettings.recallObjectiveCurrentRegionWeight, 1.15);
assert.equal(defaultSettings.recallObjectiveAdjacentRegionWeight, 0.9);
assert.equal(defaultSettings.recallObjectiveGlobalWeight, 0.75);
assert.equal(defaultSettings.injectUserPovMemory, true);
assert.equal(defaultSettings.injectObjectiveGlobalMemory, true);
assert.equal(defaultSettings.injectDepth, 9999);
assert.equal(defaultSettings.enabled, true);
assert.equal(defaultSettings.enableReflection, true);

View File

@@ -5,6 +5,10 @@ import { DEFAULT_NODE_SCHEMA } from "../schema.js";
const coreEvent = {
id: "event-1",
type: "event",
scope: {
layer: "objective",
regionPrimary: "钟楼",
},
fields: {
summary: "艾琳在钟楼发现了地下入口",
participants: "艾琳",
@@ -14,21 +18,36 @@ const coreEvent = {
const recalledCharacter = {
id: "char-1",
type: "character",
type: "pov_memory",
scope: {
layer: "pov",
ownerType: "character",
ownerId: "艾琳",
ownerName: "艾琳",
regionPrimary: "钟楼",
},
fields: {
name: "艾琳",
state: "警觉并准备进入地下室",
goal: "调查钟楼秘密",
summary: "艾琳觉得地下室入口说明钟楼里有人长期活动",
belief: "这里藏着失踪案线索",
emotion: "警觉",
attitude: "必须立刻下去查看",
},
};
const recalledReflection = {
id: "reflection-1",
type: "reflection",
id: "user-pov-1",
type: "pov_memory",
scope: {
layer: "pov",
ownerType: "user",
ownerId: "玩家",
ownerName: "玩家",
},
fields: {
insight: "地下入口意味着先前的失踪案与钟楼存在长期关联",
trigger: "钟楼发现暗门",
suggestion: "后续优先追查地下通道与失踪人口名单",
summary: "玩家已经把钟楼和失踪案牢牢绑定起来了",
belief: "钟楼地下室肯定有更深的秘密",
emotion: "紧张",
attitude: "希望艾琳谨慎推进",
},
};
@@ -36,23 +55,21 @@ const text = formatInjection(
{
coreNodes: [coreEvent],
recallNodes: [recalledCharacter, recalledReflection],
groupedRecallNodes: {
state: [recalledCharacter],
episodic: [],
reflective: [recalledReflection],
rule: [],
other: [],
scopeBuckets: {
characterPov: [recalledCharacter],
userPov: [recalledReflection],
objectiveCurrentRegion: [coreEvent],
objectiveGlobal: [],
},
},
DEFAULT_NODE_SCHEMA,
);
assert.match(text, /\[Memory - Core\]/);
assert.match(text, /\[Memory - Character POV\]/);
assert.match(text, /\[Memory - User POV \/ Not Character Facts\]/);
assert.match(text, /不等于角色已知事实/);
assert.match(text, /\[Memory - Objective \/ Current Region\]/);
assert.match(text, /pov_memory_table:/);
assert.match(text, /event_table:/);
assert.match(text, /\[Memory - Recalled\]/);
assert.match(text, /## 当前状态记忆/);
assert.match(text, /## 反思与长期锚点/);
assert.match(text, /character_table:/);
assert.match(text, /reflection_table:/);
console.log("injector-format tests passed");

View File

@@ -92,6 +92,53 @@ const graph = createGraph();
const helpers = createGraphHelpers(graph);
const retrieve = await loadRetrieve({
...helpers,
MEMORY_SCOPE_BUCKETS: {
CHARACTER_POV: "characterPov",
USER_POV: "userPov",
OBJECTIVE_CURRENT_REGION: "objectiveCurrentRegion",
OBJECTIVE_ADJACENT_REGION: "objectiveAdjacentRegion",
OBJECTIVE_GLOBAL: "objectiveGlobal",
OTHER_POV: "otherPov",
},
normalizeMemoryScope(scope = {}) {
return {
layer: scope.layer === "pov" ? "pov" : "objective",
ownerType: scope.ownerType || "",
ownerId: scope.ownerId || "",
ownerName: scope.ownerName || "",
regionPrimary: scope.regionPrimary || "",
regionPath: Array.isArray(scope.regionPath) ? scope.regionPath : [],
regionSecondary: Array.isArray(scope.regionSecondary)
? scope.regionSecondary
: [],
};
},
getScopeRegionKey(scope = {}) {
return String(scope.regionPrimary || "");
},
classifyNodeScopeBucket(node, { activeRegion = "" } = {}) {
if (node?.scope?.layer === "pov") {
return node?.scope?.ownerType === "user"
? "userPov"
: "characterPov";
}
if (
activeRegion &&
String(node?.scope?.regionPrimary || "").trim() === String(activeRegion).trim()
) {
return "objectiveCurrentRegion";
}
return "objectiveGlobal";
},
resolveScopeBucketWeight(bucket, overrides = {}) {
return Number(overrides?.[bucket] ?? 1) || 1;
},
describeMemoryScope(scope = {}) {
return `${scope.layer || "objective"}:${scope.ownerType || ""}:${scope.regionPrimary || ""}`;
},
describeScopeBucket(bucket = "") {
return String(bucket || "");
},
buildTaskPrompt() {
return { systemPrompt: "" };
},
@@ -446,4 +493,67 @@ assert.equal(lexicalResult.meta.retrieval.queryBlendActive, false);
assert.equal(lexicalResult.meta.retrieval.lexicalBoostedNodes, 1);
assert.equal(lexicalResult.meta.retrieval.lexicalTopHits[0]?.nodeId, "char-1");
const scopedGraph = {
nodes: [
{
id: "obj-global",
type: "event",
importance: 8,
createdTime: 1,
archived: false,
fields: { title: "旧王都事件" },
seqRange: [1, 1],
scope: { layer: "objective", regionPrimary: "旧城区" },
},
{
id: "char-pov",
type: "pov_memory",
importance: 4,
createdTime: 2,
archived: false,
fields: { summary: "艾琳觉得钟楼入口非常可疑" },
seqRange: [2, 2],
scope: {
layer: "pov",
ownerType: "character",
ownerId: "艾琳",
ownerName: "艾琳",
regionPrimary: "钟楼",
},
},
],
edges: [],
historyState: {
activeRegion: "钟楼",
activeCharacterPovOwner: "艾琳",
activeUserPovOwner: "玩家",
},
};
const scopedSchema = [
{ id: "event", label: "事件", alwaysInject: true },
{ id: "pov_memory", label: "主观记忆", alwaysInject: false },
];
const scopedResult = await retrieve({
graph: scopedGraph,
userMessage: "钟楼里到底有什么",
recentMessages: [],
embeddingConfig: {},
schema: scopedSchema,
options: {
topK: 2,
maxRecallNodes: 1,
enableVectorPrefilter: false,
enableGraphDiffusion: false,
enableLLMRecall: false,
enableDiversitySampling: false,
enableScopedMemory: true,
activeRegion: "钟楼",
activeCharacterPovOwner: "艾琳",
},
});
assert.deepEqual(Array.from(scopedResult.selectedNodeIds), ["char-pov"]);
assert.equal(scopedResult.meta.retrieval.activeRegion, "钟楼");
assert.ok(Array.isArray(scopedResult.scopeBuckets.characterPov));
assert.equal(scopedResult.scopeBuckets.characterPov[0]?.id, "char-pov");
console.log("retrieval-config tests passed");

88
tests/scoped-memory.mjs Normal file
View File

@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import {
addNode,
createEmptyGraph,
createNode,
deserializeGraph,
findLatestNode,
serializeGraph,
} from "../graph.js";
const graph = createEmptyGraph();
const objectiveNode = createNode({
type: "character",
fields: { name: "艾琳", state: "平静" },
seq: 1,
});
const povNode = createNode({
type: "character",
fields: { name: "艾琳", state: "怀疑一切" },
seq: 2,
scope: {
layer: "pov",
ownerType: "character",
ownerId: "艾琳",
ownerName: "艾琳",
regionPrimary: "钟楼",
},
});
addNode(graph, objectiveNode);
addNode(graph, povNode);
const latestObjective = findLatestNode(
graph,
"character",
"艾琳",
"name",
{ layer: "objective" },
);
const latestPov = findLatestNode(
graph,
"character",
"艾琳",
"name",
{
layer: "pov",
ownerType: "character",
ownerId: "艾琳",
ownerName: "艾琳",
},
);
assert.equal(latestObjective?.id, objectiveNode.id);
assert.equal(latestPov?.id, povNode.id);
const legacyGraph = deserializeGraph({
version: 5,
lastProcessedSeq: 0,
nodes: [
{
id: "legacy-1",
type: "event",
fields: { title: "旧事件", summary: "旧摘要" },
seq: 0,
seqRange: [0, 0],
archived: false,
importance: 5,
createdTime: 1,
accessCount: 0,
lastAccessTime: 1,
level: 0,
parentId: null,
childIds: [],
prevId: null,
nextId: null,
clusters: [],
},
],
edges: [],
});
assert.equal(legacyGraph.nodes[0]?.scope?.layer, "objective");
assert.equal(legacyGraph.version, 6);
const restored = deserializeGraph(serializeGraph(graph));
assert.equal(restored.nodes.find((node) => node.id === povNode.id)?.scope?.ownerType, "character");
assert.equal(restored.nodes.find((node) => node.id === povNode.id)?.scope?.regionPrimary, "钟楼");
console.log("scoped-memory tests passed");