吸收数字员工长期记忆闭环

This commit is contained in:
baiyanyun
2026-06-09 13:49:11 +08:00
parent ef8252e2dd
commit ddd39396f7
12 changed files with 817 additions and 34 deletions

View File

@@ -604,6 +604,100 @@ describe("digital employee state service", () => {
kind: "governance_resume_task",
});
});
it("upserts, lists, and syncs digital employee memories", async () => {
const {
listDigitalEmployeeMemories,
readDigitalEmployeeRuntimeRecords,
syncQimingclawMemoryEntries,
upsertDigitalEmployeeMemory,
} = await import("./stateService");
const localMemory = upsertDigitalEmployeeMemory({
key: "preferred-report-style",
content: "用户偏好日报先给结论再列证据。",
category: "preference",
source: "qimingclaw-digital-local",
score: 0.88,
payload: { source: "test" },
});
expect(localMemory).toMatchObject({
id: "memory-preferred-report-style",
key: "preferred-report-style",
category: "preference",
status: "active",
syncStatus: "pending",
});
expect(mockState.db?.outbox.get("memory:upsert:memory-preferred-report-style")).toMatchObject({
entity_type: "memory",
entity_id: "memory-preferred-report-style",
operation: "upsert",
status: "pending",
});
syncQimingclawMemoryEntries([
{
id: "mem-from-service",
text: "用户希望所有提交信息使用中文。",
category: "preference",
source: "core",
sourcePath: "MEMORY.md",
importance: 0.9,
status: "active",
createdAt: 1780800000000,
updatedAt: 1780800300000,
},
]);
expect(listDigitalEmployeeMemories({ query: "中文" })).toEqual([
expect.objectContaining({
id: "mem-from-service",
key: "mem-from-service",
content: "用户希望所有提交信息使用中文。",
}),
]);
expect(listDigitalEmployeeMemories({ category: "preference" })).toHaveLength(2);
expect(readDigitalEmployeeRuntimeRecords().memories).toEqual(expect.arrayContaining([
expect.objectContaining({ id: "mem-from-service" }),
expect.objectContaining({ id: "memory-preferred-report-style" }),
]));
});
it("soft deletes digital employee memories and writes delete outbox", async () => {
const {
deleteDigitalEmployeeMemory,
listDigitalEmployeeMemories,
upsertDigitalEmployeeMemory,
} = await import("./stateService");
upsertDigitalEmployeeMemory({
id: "memory-delete-me",
key: "delete-me",
content: "这条记忆将被删除。",
category: "fact",
});
const deleted = deleteDigitalEmployeeMemory("delete-me");
expect(deleted).toMatchObject({
id: "memory-delete-me",
key: "delete-me",
status: "deleted",
deletedAt: "2026-06-07T08:00:00.000Z",
syncStatus: "pending",
});
expect(listDigitalEmployeeMemories({ includeDeleted: false })).toEqual([]);
expect(listDigitalEmployeeMemories({ includeDeleted: true })).toEqual([
expect.objectContaining({ id: "memory-delete-me", status: "deleted" }),
]);
expect(mockState.db?.outbox.get("memory:delete:memory-delete-me")).toMatchObject({
entity_type: "memory",
entity_id: "memory-delete-me",
operation: "delete",
status: "pending",
});
});
});
function createGovernableTask(options: {
@@ -749,6 +843,23 @@ interface TestApprovalRow {
updated_at: string;
}
interface TestMemoryRow {
id: string;
key: string;
content: string;
category: string;
source: string;
source_path: string | null;
session_id: string | null;
score: number | null;
status: string;
payload: string;
sync_status: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
interface TestScheduleRow {
id: string;
plan_id: string | null;
@@ -813,6 +924,7 @@ class TestDb {
events = new Map<string, TestEventRow>();
artifacts = new Map<string, TestArtifactRow>();
approvals = new Map<string, TestApprovalRow>();
memories = new Map<string, TestMemoryRow>();
schedules = new Map<string, TestScheduleRow>();
scheduleRuns = new Map<string, TestScheduleRunRow>();
outbox = new Map<string, TestOutboxRow>();
@@ -844,6 +956,7 @@ class TestDb {
if (sql.includes("FROM digital_events")) return Array.from(this.events.values());
if (sql.includes("FROM digital_artifacts")) return Array.from(this.artifacts.values());
if (sql.includes("FROM digital_approvals")) return Array.from(this.approvals.values());
if (sql.includes("FROM digital_memories")) return Array.from(this.memories.values());
return [];
}
@@ -882,6 +995,18 @@ class TestDb {
if (sql.includes("FROM digital_schedule_runs") && sql.includes("WHERE id = ?")) {
return this.scheduleRuns.get(args[0] as string);
}
if (sql.includes("FROM digital_memories") && sql.includes("WHERE id = ? OR key = ?")) {
return Array.from(this.memories.values())
.filter((memory) => memory.id === args[0] || memory.key === args[1])
.sort((left, right) => {
const leftDeleted = left.status === "deleted" ? 1 : 0;
const rightDeleted = right.status === "deleted" ? 1 : 0;
return leftDeleted - rightDeleted || right.updated_at.localeCompare(left.updated_at) || right.created_at.localeCompare(left.created_at);
})[0];
}
if (sql.includes("FROM digital_memories") && sql.includes("WHERE id = ?")) {
return this.memories.get(args[0] as string);
}
return { count: 0 };
}
@@ -1258,6 +1383,57 @@ class TestDb {
return { changes: 1 };
}
if (sql.startsWith("INSERT INTO digital_memories")) {
const [id, key, content, category, source, sourcePath, sessionId, score, status, payload, createdAt, updatedAt, deletedAt] = args as [
string,
string,
string,
string,
string,
string | null,
string | null,
number | null,
string,
string,
string,
string,
string | null,
];
const previous = this.memories.get(id);
this.memories.set(id, {
id,
key,
content,
category,
source,
source_path: sourcePath,
session_id: sessionId,
score,
status,
payload,
sync_status: previous?.sync_status === "synced" ? "pending" : previous?.sync_status ?? "pending",
created_at: previous?.created_at ?? createdAt,
updated_at: updatedAt,
deleted_at: deletedAt,
});
return { changes: 1 };
}
if (sql.startsWith("UPDATE digital_memories")) {
const [deletedAt, updatedAt, id] = args as [string, string, string];
const previous = this.memories.get(id);
if (previous) {
this.memories.set(id, {
...previous,
status: "deleted",
deleted_at: deletedAt,
updated_at: updatedAt,
sync_status: "pending",
});
}
return { changes: previous ? 1 : 0 };
}
if (sql.startsWith("INSERT INTO digital_sync_outbox")) {
const [id, entityType, entityId, operation, payload, createdAt, updatedAt] = args as [
string,