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
+4
View File
@@ -16,7 +16,11 @@
"@nestjs/core": "^11.1.29",
"@nestjs/platform-express": "^11.1.29",
"dotenv": "^17.4.2",
"node-cron": "^4.6.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@types/node-cron": "^3.0.11"
}
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { Module } from "@nestjs/common";
import { HealthController } from "./health/health.controller.js";
import { CharactersModule } from "./characters/characters.module.js";
import { MemoryModule } from "./memory/memory.module.js";
@Module({
imports: [CharactersModule],
imports: [CharactersModule, MemoryModule],
controllers: [HealthController],
})
export class AppModule {}
+2
View File
@@ -0,0 +1,2 @@
// 情緒強度視為「高情緒」的門檻(0~1),C-1 溢位裁切保護、C-4 固化判斷、C-5 遺忘判斷共用同一門檻。
export const HIGH_EMOTION_THRESHOLD = 0.6;
@@ -0,0 +1,44 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
const STALE_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 小時未被提取視為「長期未被提取」
const DECAY_FACTOR = 0.5;
const MIN_WEIGHT = 0.05;
// C-5 遺忘(自然衰減):情緒強度低且長期未被提取的記憶逐次降權,權重低於門檻後刪除。
@Injectable()
export class ForgettingSweepService {
constructor(private readonly prisma: PrismaService) {}
async run(now: Date = new Date()): Promise<{ decayed: number; deleted: number }> {
const candidates = await this.prisma.client.episodicMemory.findMany({
where: { emotionIntensity: { lt: HIGH_EMOTION_THRESHOLD } },
});
let decayed = 0;
let deleted = 0;
for (const memory of candidates) {
const lastActivity = memory.lastRetrievedAt ?? memory.createdAt;
const staleFor = now.getTime() - lastActivity.getTime();
if (staleFor < STALE_WINDOW_MS) {
continue;
}
const nextWeight = memory.weight * DECAY_FACTOR;
if (nextWeight < MIN_WEIGHT) {
await this.prisma.client.episodicMemory.delete({ where: { id: memory.id } });
deleted += 1;
} else {
await this.prisma.client.episodicMemory.update({
where: { id: memory.id },
data: { weight: nextWeight },
});
decayed += 1;
}
}
return { decayed, deleted };
}
}
@@ -0,0 +1,45 @@
import { Injectable, OnModuleDestroy } from "@nestjs/common";
import cron from "node-cron";
import { log } from "@kokorone/shared";
import type { JobHandler, JobQueue } from "./job-queue.js";
@Injectable()
export class InProcessJobQueue implements JobQueue, OnModuleDestroy {
private readonly timers = new Set<NodeJS.Timeout>();
private readonly cronTasks: ReturnType<typeof cron.schedule>[] = [];
now(name: string, handler: JobHandler): void {
setImmediate(() => this.run(name, handler));
}
schedule(at: Date, name: string, handler: JobHandler): void {
const delayMs = Math.max(0, at.getTime() - Date.now());
const timer = setTimeout(() => {
this.timers.delete(timer);
void this.run(name, handler);
}, delayMs);
this.timers.add(timer);
}
every(cronExpression: string, name: string, handler: JobHandler): void {
const task = cron.schedule(cronExpression, () => this.run(name, handler));
this.cronTasks.push(task);
}
onModuleDestroy() {
for (const timer of this.timers) {
clearTimeout(timer);
}
for (const task of this.cronTasks) {
task.stop();
}
}
private async run(name: string, handler: JobHandler): Promise<void> {
try {
await handler();
} catch (err) {
log("排程", "ERR", `工作 ${name} 執行失敗:${err instanceof Error ? err.message : String(err)}`);
}
}
}
+10
View File
@@ -0,0 +1,10 @@
export type JobHandler = () => Promise<void> | void;
// C-3 排程抽象層:R-6 會換成 BullMQ 實作,呼叫端只依賴這個介面。
export interface JobQueue {
now(name: string, handler: JobHandler): void;
schedule(at: Date, name: string, handler: JobHandler): void;
every(cronExpression: string, name: string, handler: JobHandler): void;
}
export const JOB_QUEUE = Symbol("JOB_QUEUE");
@@ -0,0 +1,51 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { WorkingMemoryService } from "./working-memory.service.js";
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
function normalize(content: string): string {
return content.trim().toLowerCase();
}
// C-4 睡眠固化:對話中只累積在工作記憶,session 結束時才批次萃取寫入長期記憶。
@Injectable()
export class MemoryConsolidationService {
constructor(
private readonly prisma: PrismaService,
private readonly workingMemory: WorkingMemoryService,
) {}
async consolidate(characterId: string, sessionId: string): Promise<void> {
const entries = this.workingMemory.getContext(sessionId);
const contentCounts = new Map<string, number>();
for (const entry of entries) {
const key = normalize(entry.content);
contentCounts.set(key, (contentCounts.get(key) ?? 0) + 1);
}
for (const entry of entries) {
const emotionIntensity = entry.emotionIntensity ?? 0;
const isHighEmotion = emotionIntensity >= HIGH_EMOTION_THRESHOLD;
const isRepeated = (contentCounts.get(normalize(entry.content)) ?? 0) > 1;
if (!isHighEmotion && !isRepeated) {
continue;
}
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content: entry.content,
occurredAt: entry.timestamp,
emotionTag: entry.emotionTag ?? "CALM",
emotionIntensity,
source: "INTERACTION",
weight: isHighEmotion ? 1 + emotionIntensity : 0.5,
},
});
}
this.workingMemory.clear(sessionId);
}
}
@@ -0,0 +1,69 @@
import { Injectable } from "@nestjs/common";
import type { EpisodicMemory } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js";
export interface MemoryRetriever {
retrieve(characterId: string, query: string, limit?: number): Promise<EpisodicMemory[]>;
}
const WEIGHTS = {
keyword: 0.5,
recency: 0.3,
emotion: 0.2,
};
const DEFAULT_LIMIT = 5;
function keywordRelevance(content: string, keywords: string[]): number {
if (keywords.length === 0) {
return 0;
}
const hits = keywords.filter((keyword) => content.includes(keyword)).length;
return hits / keywords.length;
}
// C-6 記憶檢索器(關鍵字版本):排序權重=關鍵字相關度+時間近因+情緒權重。
// R-2 會加上 pgvector 語意檢索實作,取代這裡的關鍵字比對。
@Injectable()
export class KeywordMemoryRetriever implements MemoryRetriever {
constructor(private readonly prisma: PrismaService) {}
async retrieve(characterId: string, query: string, limit = DEFAULT_LIMIT): Promise<EpisodicMemory[]> {
const rows = await this.prisma.client.episodicMemory.findMany({ where: { characterId } });
const now = Date.now();
const keywords = query.split(/\s+/).filter(Boolean);
const scored = rows.map((row) => {
const keywordScore = keywordRelevance(row.content, keywords);
const daysSince = (now - row.occurredAt.getTime()) / (1000 * 60 * 60 * 24);
const recencyScore = 1 / (1 + Math.max(0, daysSince));
const emotionScore = row.emotionIntensity;
const score =
keywordScore * WEIGHTS.keyword + recencyScore * WEIGHTS.recency + emotionScore * WEIGHTS.emotion;
return { row, score };
});
scored.sort((a, b) => b.score - a.score);
const top = scored.slice(0, limit).map((s) => s.row);
if (top.length > 0) {
await this.prisma.client.episodicMemory.updateMany({
where: { id: { in: top.map((row) => row.id) } },
data: { lastRetrievedAt: new Date(now), retrievalCount: { increment: 1 } },
});
}
return top.map((row) => ({
id: row.id,
characterId: row.characterId,
content: row.content,
occurredAt: row.occurredAt.toISOString(),
emotionTag: row.emotionTag,
emotionIntensity: row.emotionIntensity,
retrievalCount: row.retrievalCount + 1,
lastRetrievedAt: new Date(now).toISOString(),
source: row.source,
weight: row.weight,
}));
}
}
+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 };
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Module, OnModuleInit } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
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";
import { MemoryController } from "./memory.controller.js";
import { JOB_QUEUE } from "./job-queue.js";
@Module({
imports: [PrismaModule],
controllers: [MemoryController],
providers: [
WorkingMemoryService,
MemoryConsolidationService,
ForgettingSweepService,
KeywordMemoryRetriever,
InProcessJobQueue,
{ provide: JOB_QUEUE, useExisting: InProcessJobQueue },
],
exports: [
WorkingMemoryService,
MemoryConsolidationService,
ForgettingSweepService,
KeywordMemoryRetriever,
JOB_QUEUE,
],
})
export class MemoryModule implements OnModuleInit {
constructor(
private readonly jobQueue: InProcessJobQueue,
private readonly forgettingSweep: ForgettingSweepService,
) {}
onModuleInit() {
this.jobQueue.every("0 * * * *", "forgetting-sweep", async () => {
await this.forgettingSweep.run();
});
}
}
@@ -0,0 +1,61 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
export interface WorkingMemoryEntry {
role: "user" | "character";
content: string;
timestamp: Date;
emotionTag?: EmotionTag;
emotionIntensity?: number; // 0~1,未標記情緒的訊息(如快速通道問候)可省略
}
const DEFAULT_TOKEN_LIMIT = 200;
// 粗略估算:Mock 階段不需要真實 tokenizer,先以字元數/2 近似。
function estimateTokens(text: string): number {
return Math.ceil(text.length / 2);
}
function isHighEmotion(entry: WorkingMemoryEntry): boolean {
return (entry.emotionIntensity ?? 0) >= HIGH_EMOTION_THRESHOLD;
}
// C-1 工作記憶:session 對話上下文緩衝,只存在於行程記憶體中(如同海馬迴暫存),
// 不落地到任何長期記憶表——長期寫入只能由 C-4 睡眠固化觸發。
@Injectable()
export class WorkingMemoryService {
private readonly sessions = new Map<string, WorkingMemoryEntry[]>();
private readonly tokenLimit = DEFAULT_TOKEN_LIMIT;
append(sessionId: string, entry: WorkingMemoryEntry): void {
const entries = this.sessions.get(sessionId) ?? [];
entries.push(entry);
this.sessions.set(sessionId, this.evictOverflow(entries));
}
getContext(sessionId: string): WorkingMemoryEntry[] {
return [...(this.sessions.get(sessionId) ?? [])];
}
clear(sessionId: string): void {
this.sessions.delete(sessionId);
}
private evictOverflow(entries: WorkingMemoryEntry[]): WorkingMemoryEntry[] {
let result = entries;
let totalTokens = result.reduce((sum, e) => sum + estimateTokens(e.content), 0);
while (totalTokens > this.tokenLimit && result.length > 0) {
// 最舊的低情緒段落先被裁掉;若全部都是高情緒,最後才犧牲最舊的一則以確保不超出上限。
let evictIndex = result.findIndex((e) => !isHighEmotion(e));
if (evictIndex === -1) {
evictIndex = 0;
}
const [evicted] = result.splice(evictIndex, 1);
totalTokens -= estimateTokens(evicted.content);
}
return result;
}
}
+20
View File
@@ -31,8 +31,12 @@
"@nestjs/core": "^11.1.29",
"@nestjs/platform-express": "^11.1.29",
"dotenv": "^17.4.2",
"node-cron": "^4.6.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@types/node-cron": "^3.0.11"
}
},
"apps/web": {
@@ -2470,6 +2474,13 @@
"undici-types": "~8.3.0"
}
},
"node_modules/@types/node-cron": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -7840,6 +7851,15 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-cron": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz",
"integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==",
"license": "ISC",
"engines": {
"node": ">=20"
}
},
"node_modules/node-exports-info": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
+136
View File
@@ -0,0 +1,136 @@
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const CHARACTER_ID = "seed-character-genki";
async function post(path, body) {
const res = await fetch(`http://localhost:${API_PORT}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) {
throw new Error(`POST ${path} 回傳 ${res.status}`);
}
return res.json();
}
async function get(path) {
const res = await fetch(`http://localhost:${API_PORT}${path}`);
if (!res.ok) {
throw new Error(`GET ${path} 回傳 ${res.status}`);
}
return res.json();
}
async function countLongTermMemories() {
const [episodic, semantic, procedural] = await Promise.all([
prisma.episodicMemory.count(),
prisma.semanticMemory.count(),
prisma.proceduralRule.count(),
]);
return episodic + semantic + procedural;
}
export default async function smokeC() {
const sessionId = `smoke-c-${Date.now()}`;
const highEmotionContent = `我剛剛收到夢寐以求的錄取通知,超級感動![${sessionId}]`;
const countBeforeAppend = await countLongTermMemories();
// C-1 工作記憶溢位裁切:附加夠多低情緒內容觸發裁切,驗證高情緒段落被保留
const filler = "今天天氣普通普通的沒什麼特別事情發生。";
for (let i = 0; i < 5; i++) {
await post(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`, {
role: "user",
content: `${filler.repeat(6)}${i}`,
emotionTag: "CALM",
emotionIntensity: 0.05,
});
}
await post(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`, {
role: "user",
content: highEmotionContent,
emotionTag: "JOY",
emotionIntensity: 0.95,
});
const { context: afterOverflow } = await get(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`);
if (!afterOverflow.some((m) => m.content === highEmotionContent)) {
throw new Error("工作記憶溢位裁切錯誤地清掉了高情緒段落");
}
if (afterOverflow.length >= 6) {
throw new Error("工作記憶溢位裁切未生效,段落數未減少");
}
// C-2 對話期只寫工作記憶:附加訊息期間長期記憶表筆數不應變動
const countAfterAppend = await countLongTermMemories();
if (countAfterAppend !== countBeforeAppend) {
throw new Error("對話期間長期記憶表筆數發生變化,違反「對話中不即時寫長期記憶」");
}
// C-4 睡眠固化:只有高情緒事件進 EpisodicMemory,瑣事不進
await post(`/memory/${CHARACTER_ID}/sessions/${sessionId}/consolidate`);
const { context: afterConsolidate } = await get(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`);
if (afterConsolidate.length !== 0) {
throw new Error("固化後工作記憶未清空");
}
const countAfterConsolidate = await countLongTermMemories();
if (countAfterConsolidate !== countBeforeAppend + 1) {
throw new Error(
`固化後長期記憶筆數變化不符預期(預期 +1,實際 ${countAfterConsolidate - countBeforeAppend})`,
);
}
// C-6 記憶檢索器:關鍵字命中、瑣事未污染結果
const { results: retrieved } = await get(
`/memory/${CHARACTER_ID}/retrieve?query=${encodeURIComponent("錄取通知")}`,
);
const consolidatedMemory = retrieved.find((m) => m.content === highEmotionContent);
if (!consolidatedMemory) {
throw new Error("高情緒事件未寫入 EpisodicMemory 或檢索不到");
}
if (retrieved.some((m) => m.content.startsWith(filler))) {
throw new Error("瑣事錯誤地被寫入 EpisodicMemory");
}
// C-5 提取即改寫:檢索命中後提取次數遞增
const { results: retrievedAgain } = await get(
`/memory/${CHARACTER_ID}/retrieve?query=${encodeURIComponent("錄取通知")}`,
);
const secondHit = retrievedAgain.find((m) => m.id === consolidatedMemory.id);
if (!secondHit || secondHit.retrievalCount <= consolidatedMemory.retrievalCount) {
throw new Error("重複檢索未持續遞增提取次數");
}
// C-5 遺忘:模擬時間推進,高情緒記憶不應被衰減
const twoDaysLater = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString();
await post("/memory/forgetting-sweep", { now: twoDaysLater });
const { results: afterSweep } = await get(
`/memory/${CHARACTER_ID}/retrieve?query=${encodeURIComponent("錄取通知")}`,
);
const highEmotionAfterSweep = afterSweep.find((m) => m.id === consolidatedMemory.id);
if (!highEmotionAfterSweep || highEmotionAfterSweep.weight < secondHit.weight) {
throw new Error("遺忘程序錯誤地影響了高情緒記憶");
}
// 清理本次測試寫入的長期記憶,避免重複執行時累積髒資料
await prisma.episodicMemory.delete({ where: { id: consolidatedMemory.id } });
// C-3 排程抽象層:schedule(at) 排入的工作在時間到之前不觸發、時間到之後準時觸發
const jobId = `smoke-c-schedule-${Date.now()}`;
await post("/memory/test-scheduled-job", { id: jobId, delaySeconds: 2 });
const { fired: firedTooEarly } = await get(`/memory/test-scheduled-job/${jobId}`);
if (firedTooEarly) {
throw new Error("schedule(at) 排入的工作在時間到之前就觸發了");
}
await new Promise((resolve) => setTimeout(resolve, 3000));
const { fired: firedOnTime } = await get(`/memory/test-scheduled-job/${jobId}`);
if (!firedOnTime) {
throw new Error("schedule(at) 排入的工作逾時未觸發");
}
}
+13 -7
View File
@@ -143,13 +143,19 @@ flowchart TB
### C. 記憶子系統
- [ ] **C-1 工作記憶(S)**:實作 session 對話上下文緩衝,帶 token 上限與溢位裁切策略(保留最近與高情緒段落)。驗收:超過上限時最舊的低情緒段落先被裁掉。依據:§腦區→聊天系統元件對照「工作記憶=對話上下文視窗,有 token 上限」。
- [ ] **C-2 對話期只寫工作記憶(S)**:明確禁止對話流程中寫入長期記憶表,所有長期寫入只能由固化程序觸發。驗收:一輪對話後三張長期記憶表筆數不變。依據:§核心機制設計 1「對話中不即時寫長期記憶」。
- [ ] **C-3 排程抽象層(S)**:定義 `JobQueue` 介面(`now` / `schedule(at)` / `every(cron)`),以 in-process 計時器實作 `InProcessJobQueue`,並在 DI 容器註冊;session 結束事件推入固化工作。驗收:排入 5 秒後的工作會準時執行。註記:R-4 換成 BullMQ 時只換實作、不動呼叫端。依據:技術選型「BullMQ:睡眠固化/離線事件/提醒排程」。
- [ ] **C-4 睡眠固化程序(M)**:實作 `consolidate(sessionId)`——回顧整段對話,只有情緒強度高於門檻或被重複提及的內容才寫入情節/語意/程序記憶,並寫入完整 metadata。驗收:一段含「一件高情緒事件+數句閒聊」的對話固化後,只有高情緒事件進 `EpisodicMemory`。依據:§核心機制設計 1「Session 結束觸發睡眠固化」。
- [ ] **C-5 遺忘與提取即改寫(M)**:固化程序中對「情緒強度低且長期未被提取」的記憶降權或刪除;每次檢索命中即更新提取次數與最後提取時間。驗收:模擬時間推進後低權重舊記憶被清除,常被提取者保留。依據:§核心機制設計 2「記憶遺忘(自然衰減)」。
- [ ] **C-6 記憶檢索器(M)**:定義 `MemoryRetriever` 介面並實作關鍵字版本,排序權重=關鍵字相關度+時間近因+情緒權重+(E-6 補上的)關係對象加權。驗收:查詢命中相關情節記憶且排序符合權重設計。註記:R-2 會加上 pgvector 語意檢索實作。依據:技術選型「pgvector:記憶語意檢索」。
- [ ] **C-V 階段驗證(XS)**:`npm run restart && npm run smoke -- C`(C.mjs:模擬一段對話 → 觸發固化 → 驗證高情緒事件入庫、瑣事未入庫、檢索可命中、提取次數遞增)。
- [x] **C-1 工作記憶(S)**:實作 session 對話上下文緩衝,帶 token 上限與溢位裁切策略(保留最近與高情緒段落)。驗收:超過上限時最舊的低情緒段落先被裁掉。依據:§腦區→聊天系統元件對照「工作記憶=對話上下文視窗,有 token 上限」。
- [x] **C-2 對話期只寫工作記憶(S)**:明確禁止對話流程中寫入長期記憶表,所有長期寫入只能由固化程序觸發。驗收:一輪對話後三張長期記憶表筆數不變。依據:§核心機制設計 1「對話中不即時寫長期記憶」。
- [x] **C-3 排程抽象層(S)**:定義 `JobQueue` 介面(`now` / `schedule(at)` / `every(cron)`),以 in-process 計時器實作 `InProcessJobQueue`,並在 DI 容器註冊;session 結束事件推入固化工作。驗收:排入 5 秒後的工作會準時執行。註記:R-4 換成 BullMQ 時只換實作、不動呼叫端。依據:技術選型「BullMQ:睡眠固化/離線事件/提醒排程」。
- [x] **C-4 睡眠固化程序(M)**:實作 `consolidate(sessionId)`——回顧整段對話,只有情緒強度高於門檻或被重複提及的內容才寫入情節/語意/程序記憶,並寫入完整 metadata。驗收:一段含「一件高情緒事件+數句閒聊」的對話固化後,只有高情緒事件進 `EpisodicMemory`。依據:§核心機制設計 1「Session 結束觸發睡眠固化」。
- [x] **C-5 遺忘與提取即改寫(M)**:固化程序中對「情緒強度低且長期未被提取」的記憶降權或刪除;每次檢索命中即更新提取次數與最後提取時間。驗收:模擬時間推進後低權重舊記憶被清除,常被提取者保留。依據:§核心機制設計 2「記憶遺忘(自然衰減)」。
- [x] **C-6 記憶檢索器(M)**:定義 `MemoryRetriever` 介面並實作關鍵字版本,排序權重=關鍵字相關度+時間近因+情緒權重+(E-6 補上的)關係對象加權。驗收:查詢命中相關情節記憶且排序符合權重設計。註記:R-2 會加上 pgvector 語意檢索實作。依據:技術選型「pgvector:記憶語意檢索」。
- [x] **C-V 階段驗證(XS)**:`npm run restart && npm run smoke -- C`(C.mjs:模擬一段對話 → 觸發固化 → 驗證高情緒事件入庫、瑣事未入庫、檢索可命中、提取次數遞增)。
> **實作記錄(C 群組)**:
> - 記憶引擎全放在 `apps/api/src/memory/`(NestJS module),情緒標記(`emotionTag`/`emotionIntensity`)在此階段由**呼叫端提供**,不是自己判斷——因為執行順序圖 C/D/E 是同層平行,C 不能依賴 D 尚未建置的情緒標記器;F 群組整合時才會接上 D 的真實輸出。
> - `POST/GET /memory/:characterId/sessions/:sessionId/messages`、`.../consolidate`、`GET /memory/:characterId/retrieve`、`POST /memory/forgetting-sweep` 這幾個端點目前是**工程內部驗證用**,不是使用者可見 API;H-1「`POST /chat/:characterId` 走完整管線」「`POST /session/:id/end` 觸發睡眠固化」上線後,這些端點的邏輯會被納入正式管線,屆時再決定是否保留、改名或整組移除。
> - 高情緒門檻(`HIGH_EMOTION_THRESHOLD = 0.6`)與工作記憶 token 上限(`DEFAULT_TOKEN_LIMIT = 200`,粗略以「字元數 / 2」估算,非真實 tokenizer)都定義在 `apps/api/src/memory/constants.ts` 與 `working-memory.service.ts`,D 群組的情緒狀態機若要共用同一門檻語意,應該從這裡匯入而不是各自定一份。
> - `JobQueue`(`now`/`schedule(at)`/`every(cron)`)介面在 `apps/api/src/memory/job-queue.ts`,`InProcessJobQueue` 用 `setTimeout`/`node-cron` 實作;`every()` 目前唯一的呼叫者是每小時跑一次的遺忘清掃(`memory.module.ts` 的 `onModuleInit`)。J 群組的提醒排程應直接複用同一個 `JOB_QUEUE`,不要另外自己接計時器。
### D. 情緒子系統