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

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

@@ -218,6 +218,26 @@ export interface DigitalEmployeeApprovalRecord {
updatedAt: string;
}
export interface DigitalEmployeeMemoryRecord {
id: string;
remoteId?: string | null;
key: string;
content: string;
category: string;
source: string;
sourcePath?: string | null;
sessionId?: string | null;
score?: number | null;
status: string;
payload: unknown;
syncStatus: string;
lastSyncedAt?: string | null;
syncError?: string | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
export interface DigitalEmployeeRuntimeRecords {
generatedAt: string;
plans: DigitalEmployeePlanRecord[];
@@ -229,6 +249,39 @@ export interface DigitalEmployeeRuntimeRecords {
events: DigitalEmployeeEventRecord[];
artifacts: DigitalEmployeeArtifactRecord[];
approvals: DigitalEmployeeApprovalRecord[];
memories: DigitalEmployeeMemoryRecord[];
}
export interface DigitalEmployeeMemoryUpsertInput {
id?: string;
key?: string;
content: string;
category?: string;
source?: string;
sourcePath?: string | null;
sessionId?: string | null;
score?: number | null;
status?: string;
payload?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
}
export interface DigitalEmployeeQimingMemoryEntry {
id?: string;
text?: string;
content?: string;
key?: string;
category?: string;
source?: string;
sourcePath?: string;
sessionId?: string | null;
confidence?: number;
importance?: number;
status?: string;
createdAt?: number | string;
updatedAt?: number | string;
}
export interface DigitalEmployeeUiState {
@@ -624,6 +677,7 @@ export function readDigitalEmployeeRuntimeRecords(
events: [],
artifacts: [],
approvals: [],
memories: [],
};
}
const safeLimit = Math.max(1, Math.min(Math.floor(limit), 300));
@@ -695,6 +749,14 @@ export function readDigitalEmployeeRuntimeRecords(
ORDER BY updated_at DESC, created_at DESC
LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>;
const memories = db.prepare(`
SELECT id, remote_id, key, content, category, source, source_path,
session_id, score, status, payload, sync_status, last_synced_at,
sync_error, created_at, updated_at, deleted_at
FROM digital_memories
ORDER BY updated_at DESC, created_at DESC
LIMIT ?
`).all(safeLimit) as Array<Record<string, unknown>>;
return {
generatedAt: new Date().toISOString(),
@@ -707,9 +769,86 @@ export function readDigitalEmployeeRuntimeRecords(
events: events.map(toEventRecord),
artifacts: artifacts.map(toArtifactRecord),
approvals: approvals.map(toApprovalRecord),
memories: memories.map(toMemoryRecord),
};
}
export function listDigitalEmployeeMemories(options: {
query?: string | null;
category?: string | null;
limit?: number;
includeDeleted?: boolean;
} = {}): DigitalEmployeeMemoryRecord[] {
const db = getDigitalEmployeeDb();
if (!db) return [];
const rows = db.prepare(`
SELECT id, remote_id, key, content, category, source, source_path,
session_id, score, status, payload, sync_status, last_synced_at,
sync_error, created_at, updated_at, deleted_at
FROM digital_memories
ORDER BY updated_at DESC, created_at DESC
LIMIT ?
`).all(Math.max(1, Math.min(Math.floor(options.limit ?? 200), 1000))) as Array<Record<string, unknown>>;
const query = stringValue(options.query).toLowerCase();
const category = stringValue(options.category).toLowerCase();
return rows
.map(toMemoryRecord)
.filter((memory) => options.includeDeleted === true || memory.status !== "deleted")
.filter((memory) => !category || memory.category.toLowerCase() === category)
.filter((memory) => {
if (!query) return true;
return `${memory.key} ${memory.content} ${memory.category} ${memory.source}`.toLowerCase().includes(query);
});
}
export function upsertDigitalEmployeeMemory(
input: DigitalEmployeeMemoryUpsertInput,
): DigitalEmployeeMemoryRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = new Date().toISOString();
const normalized = normalizeMemoryInput(input, now);
const tx = db.transaction(() => upsertMemory(normalized));
tx();
return readDigitalEmployeeMemory(normalized.id);
}
export function deleteDigitalEmployeeMemory(
keyOrId: string,
): DigitalEmployeeMemoryRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = new Date().toISOString();
const target = readDigitalEmployeeMemoryByKeyOrId(keyOrId);
if (!target) return null;
const tx = db.transaction(() => {
db.prepare(`
UPDATE digital_memories
SET status = 'deleted', deleted_at = ?, updated_at = ?, sync_status = 'pending'
WHERE id = ?
`).run(now, now, target.id);
markOutbox("memory", target.id, "delete", { ...target, status: "deleted", deletedAt: now, updatedAt: now }, now);
});
tx();
return readDigitalEmployeeMemory(target.id);
}
export function syncQimingclawMemoryEntries(
entries: DigitalEmployeeQimingMemoryEntry[],
): DigitalEmployeeMemoryRecord[] {
const db = getDigitalEmployeeDb();
if (!db) return [];
const now = new Date().toISOString();
const records = entries.map((entry) => normalizeQimingMemoryEntry(entry, now));
const tx = db.transaction(() => {
for (const record of records) upsertMemory(record);
});
tx();
return records
.map((record) => readDigitalEmployeeMemory(record.id))
.filter((record): record is DigitalEmployeeMemoryRecord => Boolean(record));
}
export function listDueDigitalEmployeeSchedules(
nowIso: string,
limit = 20,
@@ -2452,6 +2591,28 @@ function toApprovalRecord(
};
}
function toMemoryRecord(row: Record<string, unknown>): DigitalEmployeeMemoryRecord {
return {
id: stringField(row, "id"),
remoteId: optionalStringField(row, "remote_id"),
key: stringField(row, "key"),
content: stringField(row, "content"),
category: stringField(row, "category") || "fact",
source: stringField(row, "source") || "qimingclaw",
sourcePath: optionalStringField(row, "source_path"),
sessionId: optionalStringField(row, "session_id"),
score: typeof row.score === "number" ? row.score : row.score == null ? null : Number(row.score) || null,
status: stringField(row, "status") || "active",
payload: parseStoredPayload(row.payload),
syncStatus: stringField(row, "sync_status"),
lastSyncedAt: optionalStringField(row, "last_synced_at"),
syncError: optionalStringField(row, "sync_error"),
createdAt: stringField(row, "created_at"),
updatedAt: stringField(row, "updated_at"),
deletedAt: optionalStringField(row, "deleted_at"),
};
}
function countPendingSyncItems(): number {
const db = getDigitalEmployeeDb();
if (!db) return 0;
@@ -2494,6 +2655,154 @@ function markOutbox(
);
}
function normalizeMemoryInput(
input: DigitalEmployeeMemoryUpsertInput,
now: string,
): Required<Pick<DigitalEmployeeMemoryUpsertInput, "id" | "key" | "content" | "category" | "source" | "payload">> & {
sourcePath: string | null;
sessionId: string | null;
score: number | null;
status: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
} {
const content = stringValue(input.content);
const key = stringValue(input.key) || memoryKeyFromContent(content);
return {
id: stringValue(input.id) || `memory-${safeId(key)}`,
key,
content,
category: stringValue(input.category) || "fact",
source: stringValue(input.source) || "qimingclaw-digital-local",
sourcePath: stringValue(input.sourcePath) || null,
sessionId: stringValue(input.sessionId) || null,
score: typeof input.score === "number" && Number.isFinite(input.score) ? input.score : null,
status: stringValue(input.status) || "active",
payload: input.payload ?? {},
createdAt: input.createdAt || now,
updatedAt: input.updatedAt || now,
deletedAt: input.deletedAt ?? null,
};
}
function normalizeQimingMemoryEntry(
entry: DigitalEmployeeQimingMemoryEntry,
now: string,
): ReturnType<typeof normalizeMemoryInput> {
const content = stringValue(entry.text ?? entry.content);
const createdAt = memoryTimestampToIso(entry.createdAt) || now;
const updatedAt = memoryTimestampToIso(entry.updatedAt) || createdAt;
const score = typeof entry.importance === "number"
? entry.importance
: typeof entry.confidence === "number"
? entry.confidence
: null;
return normalizeMemoryInput({
id: stringValue(entry.id) || undefined,
key: stringValue(entry.key) || stringValue(entry.id) || memoryKeyFromContent(content),
content,
category: stringValue(entry.category) || "fact",
source: stringValue(entry.source) || "qimingclaw-memory-service",
sourcePath: stringValue(entry.sourcePath) || null,
sessionId: stringValue(entry.sessionId) || null,
score,
status: stringValue(entry.status) || "active",
payload: {
source: "qimingclaw-memory-service",
original: entry,
},
createdAt,
updatedAt,
deletedAt: stringValue(entry.status) === "deleted" ? updatedAt : null,
}, now);
}
function memoryTimestampToIso(value: number | string | undefined): string | null {
if (typeof value === "number" && Number.isFinite(value)) {
return new Date(value).toISOString();
}
if (typeof value === "string" && value.trim()) {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
}
return null;
}
function memoryKeyFromContent(content: string): string {
return safeId(content.slice(0, 80) || `memory-${Date.now()}`);
}
function readDigitalEmployeeMemory(id: string): DigitalEmployeeMemoryRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const row = db.prepare(`
SELECT id, remote_id, key, content, category, source, source_path,
session_id, score, status, payload, sync_status, last_synced_at,
sync_error, created_at, updated_at, deleted_at
FROM digital_memories
WHERE id = ?
`).get(id) as Record<string, unknown> | undefined;
return row ? toMemoryRecord(row) : null;
}
function readDigitalEmployeeMemoryByKeyOrId(keyOrId: string): DigitalEmployeeMemoryRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const row = db.prepare(`
SELECT id, remote_id, key, content, category, source, source_path,
session_id, score, status, payload, sync_status, last_synced_at,
sync_error, created_at, updated_at, deleted_at
FROM digital_memories
WHERE id = ? OR key = ?
ORDER BY CASE WHEN status = 'deleted' THEN 1 ELSE 0 END ASC, updated_at DESC, created_at DESC
LIMIT 1
`).get(keyOrId, keyOrId) as Record<string, unknown> | undefined;
return row ? toMemoryRecord(row) : null;
}
function upsertMemory(input: ReturnType<typeof normalizeMemoryInput>): void {
const db = getDigitalEmployeeDb();
if (!db) return;
db.prepare(`
INSERT INTO digital_memories (
id, key, content, category, source, source_path, session_id, score,
status, payload, sync_status, created_at, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
key = excluded.key,
content = excluded.content,
category = excluded.category,
source = excluded.source,
source_path = excluded.source_path,
session_id = excluded.session_id,
score = excluded.score,
status = excluded.status,
payload = excluded.payload,
deleted_at = excluded.deleted_at,
sync_status = CASE
WHEN digital_memories.sync_status = 'synced' THEN 'pending'
ELSE digital_memories.sync_status
END,
updated_at = excluded.updated_at
`).run(
input.id,
input.key,
input.content,
input.category,
input.source,
input.sourcePath,
input.sessionId,
input.score,
input.status,
stringify(input.payload),
input.createdAt,
input.updatedAt,
input.deletedAt,
);
markOutbox("memory", input.id, input.deletedAt ? "delete" : "upsert", input, input.updatedAt);
}
function normalizeScheduleInput(
input: DigitalEmployeeScheduleUpsertInput,
now: string,