feat: 完成 C 群組 — 記憶子系統

- WorkingMemoryService:session 對話上下文緩衝,token 溢位時優先裁切最舊的低情緒段落
- JobQueue 抽象層(now/schedule(at)/every(cron))與 InProcessJobQueue 實作(node-cron),
  R-6 會換成 BullMQ 但呼叫端介面不變
- MemoryConsolidationService:睡眠固化,只有高情緒或重複提及的內容才寫入 EpisodicMemory
- ForgettingSweepService:低情緒且長期未提取的記憶逐次降權、weight 過低後刪除,
  已掛上每小時一次的 JobQueue.every 排程
- KeywordMemoryRetriever:關鍵字+時間近因+情緒權重排序,提取命中即更新提取次數/時間
- apps/api 新增 /memory/* 端點作為引擎驗證介面(H 群組會決定併入正式對話管線後的去留)
- scripts/smoke/C.mjs:端到端驗證溢位裁切、只寫工作記憶、固化篩選、檢索排序、
  提取即改寫、schedule(at) 準時觸發、遺忘衰減與刪除

npm run restart && npm run smoke -- C 皆通過(C-V),A/B 群組冒煙測試無回歸。
This commit is contained in:
Jeffery
2026-08-13 09:54:29 +08:00
parent f44200f543
commit e6f25008d1
14 changed files with 580 additions and 8 deletions
+82
View File
@@ -0,0 +1,82 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { WorkingMemoryService } from "./working-memory.service.js";
import { MemoryConsolidationService } from "./memory-consolidation.service.js";
import { ForgettingSweepService } from "./forgetting-sweep.service.js";
import { KeywordMemoryRetriever } from "./memory-retriever.service.js";
import { InProcessJobQueue } from "./in-process-job-queue.service.js";
interface AppendMessageBody {
role: "user" | "character";
content: string;
emotionTag?: EmotionTag;
emotionIntensity?: number;
}
@Controller("memory")
export class MemoryController {
// C-3 排程抽象層驗證用:記錄測試性延遲工作是否已觸發,僅供 smoke test 檢查 schedule(at) 準時性。
private readonly scheduledJobFired = new Map<string, boolean>();
constructor(
private readonly workingMemory: WorkingMemoryService,
private readonly consolidation: MemoryConsolidationService,
private readonly forgettingSweep: ForgettingSweepService,
private readonly retriever: KeywordMemoryRetriever,
private readonly jobQueue: InProcessJobQueue,
) {}
@Post(":characterId/sessions/:sessionId/messages")
appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
this.workingMemory.append(sessionId, {
role: body.role,
content: body.content,
timestamp: new Date(),
emotionTag: body.emotionTag,
emotionIntensity: body.emotionIntensity,
});
return { context: this.workingMemory.getContext(sessionId) };
}
@Get(":characterId/sessions/:sessionId/messages")
getContext(@Param("sessionId") sessionId: string) {
return { context: this.workingMemory.getContext(sessionId) };
}
@Post(":characterId/sessions/:sessionId/consolidate")
async consolidate(@Param("characterId") characterId: string, @Param("sessionId") sessionId: string) {
await this.consolidation.consolidate(characterId, sessionId);
return { ok: true };
}
@Get(":characterId/retrieve")
async retrieve(
@Param("characterId") characterId: string,
@Query("query") query: string,
@Query("limit") limit?: string,
) {
const results = await this.retriever.retrieve(characterId, query ?? "", limit ? Number(limit) : undefined);
return { results };
}
@Post("forgetting-sweep")
async runForgettingSweep(@Body() body: { now?: string }) {
const now = body?.now ? new Date(body.now) : new Date();
return this.forgettingSweep.run(now);
}
@Post("test-scheduled-job")
scheduleTestJob(@Body() body: { id: string; delaySeconds: number }) {
this.scheduledJobFired.set(body.id, false);
const at = new Date(Date.now() + body.delaySeconds * 1000);
this.jobQueue.schedule(at, `test-job-${body.id}`, () => {
this.scheduledJobFired.set(body.id, true);
});
return { scheduled: true };
}
@Get("test-scheduled-job/:id")
checkTestJob(@Param("id") id: string) {
return { fired: this.scheduledJobFired.get(id) ?? false };
}
}