feat(personas): add configurable memory policy engine
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
applyPersonaMemoryExtraction,
|
||||
buildPersonaMemoryExtractionPrompt,
|
||||
normalizePersonaMemoryPolicy,
|
||||
} from "./persona-memory-policy.js";
|
||||
|
||||
describe("persona-memory-policy", () => {
|
||||
it("clamps invalid policy inputs into a coherent range", () => {
|
||||
const policy = normalizePersonaMemoryPolicy({
|
||||
extraction: {
|
||||
minFacts: 9,
|
||||
maxFacts: 2,
|
||||
updateEveryResponses: 0,
|
||||
recentMessagesWindow: -1,
|
||||
},
|
||||
pruning: {
|
||||
workingFactsLimit: 1,
|
||||
compatFactsLimit: 99,
|
||||
workingSourceMessagesLimit: 0,
|
||||
archivalFactsLimit: 0,
|
||||
archivalSummariesLimit: 0,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(policy.extraction.updateEveryResponses, 1);
|
||||
assert.equal(policy.extraction.minFacts, 1);
|
||||
assert.equal(policy.extraction.maxFacts, 1);
|
||||
assert.equal(policy.extraction.recentMessagesWindow, 1);
|
||||
assert.equal(policy.pruning.workingFactsLimit, 1);
|
||||
assert.equal(policy.pruning.compatFactsLimit, 1);
|
||||
assert.equal(policy.pruning.workingSourceMessagesLimit, 1);
|
||||
assert.equal(policy.pruning.archivalFactsLimit, 1);
|
||||
assert.equal(policy.pruning.archivalSummariesLimit, 1);
|
||||
});
|
||||
|
||||
it("builds prompts and extracted memories from the configured policy", () => {
|
||||
const policy = normalizePersonaMemoryPolicy({
|
||||
extraction: {
|
||||
minFacts: 1,
|
||||
maxFacts: 2,
|
||||
recentMessagesWindow: 2,
|
||||
},
|
||||
pruning: {
|
||||
workingFactsLimit: 3,
|
||||
workingSourceMessagesLimit: 2,
|
||||
archivalFactsLimit: 4,
|
||||
archivalSummariesLimit: 2,
|
||||
compatFactsLimit: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const prompt = buildPersonaMemoryExtractionPrompt(
|
||||
{ nick: "Schaeffer" },
|
||||
["msg 1", "msg 2", "msg 3"],
|
||||
policy,
|
||||
);
|
||||
assert.match(prompt, /Extrais 1-2 faits/);
|
||||
assert.doesNotMatch(prompt, /msg 1/);
|
||||
assert.match(prompt, /msg 2/);
|
||||
assert.match(prompt, /msg 3/);
|
||||
|
||||
const memory = applyPersonaMemoryExtraction({
|
||||
personaId: "schaeffer",
|
||||
nick: "Schaeffer",
|
||||
facts: ["fait 1", "fait 2"],
|
||||
summary: "ancien resume",
|
||||
lastUpdated: "2026-03-20T10:00:00.000Z",
|
||||
archivalMemory: {
|
||||
facts: [],
|
||||
summaries: [],
|
||||
},
|
||||
}, {
|
||||
facts: ["fait 2", "fait 3", "fait 4"],
|
||||
summary: "nouveau resume",
|
||||
}, {
|
||||
policy,
|
||||
recentMessages: ["m1", "m2", "m3"],
|
||||
timestamp: "2026-03-25T20:00:00.000Z",
|
||||
});
|
||||
|
||||
assert.deepEqual(memory.workingMemory?.facts, ["fait 2", "fait 3", "fait 4"]);
|
||||
assert.deepEqual(memory.workingMemory?.lastSourceMessages, ["m2", "m3"]);
|
||||
assert.equal(memory.summary, "nouveau resume");
|
||||
assert.equal(memory.archivalMemory?.facts.length, 3);
|
||||
assert.equal(memory.archivalMemory?.summaries.length, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import type {
|
||||
ChatPersona,
|
||||
PersonaArchivalFact,
|
||||
PersonaArchivalSummary,
|
||||
PersonaMemory,
|
||||
} from "./chat-types.js";
|
||||
|
||||
export interface PersonaMemoryPolicy {
|
||||
extraction: {
|
||||
updateEveryResponses: number;
|
||||
minFacts: number;
|
||||
maxFacts: number;
|
||||
recentMessagesWindow: number;
|
||||
};
|
||||
pruning: {
|
||||
workingFactsLimit: number;
|
||||
workingSourceMessagesLimit: number;
|
||||
archivalFactsLimit: number;
|
||||
archivalSummariesLimit: number;
|
||||
compatFactsLimit: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PersonaMemoryPolicyInput {
|
||||
extraction?: Partial<PersonaMemoryPolicy["extraction"]>;
|
||||
pruning?: Partial<PersonaMemoryPolicy["pruning"]>;
|
||||
}
|
||||
|
||||
const DEFAULT_POLICY: PersonaMemoryPolicy = {
|
||||
extraction: {
|
||||
updateEveryResponses: 5,
|
||||
minFacts: 2,
|
||||
maxFacts: 3,
|
||||
recentMessagesWindow: 10,
|
||||
},
|
||||
pruning: {
|
||||
workingFactsLimit: 20,
|
||||
workingSourceMessagesLimit: 10,
|
||||
archivalFactsLimit: 100,
|
||||
archivalSummariesLimit: 50,
|
||||
compatFactsLimit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
function parseInteger(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(String(value || "").trim(), 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function clampInteger(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(Math.trunc(value), min), max);
|
||||
}
|
||||
|
||||
function cloneArchivalFacts(values: PersonaArchivalFact[]): PersonaArchivalFact[] {
|
||||
return values.map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
function cloneArchivalSummaries(values: PersonaArchivalSummary[]): PersonaArchivalSummary[] {
|
||||
return values.map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
export function trimUniqueStrings(values: Iterable<unknown>, limit: number): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const text = String(value || "").trim();
|
||||
const key = text.toLowerCase();
|
||||
if (!text || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(text);
|
||||
}
|
||||
|
||||
return result.slice(-Math.max(1, limit));
|
||||
}
|
||||
|
||||
export function trimRecentMessages(values: Iterable<unknown>, limit: number): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) continue;
|
||||
result.push(text);
|
||||
}
|
||||
|
||||
return result.slice(-Math.max(1, limit));
|
||||
}
|
||||
|
||||
export function normalizePersonaMemoryPolicy(input: PersonaMemoryPolicyInput = {}): PersonaMemoryPolicy {
|
||||
const workingFactsLimit = clampInteger(
|
||||
input.pruning?.workingFactsLimit ?? DEFAULT_POLICY.pruning.workingFactsLimit,
|
||||
1,
|
||||
500,
|
||||
);
|
||||
const maxFacts = clampInteger(
|
||||
input.extraction?.maxFacts ?? DEFAULT_POLICY.extraction.maxFacts,
|
||||
1,
|
||||
workingFactsLimit,
|
||||
);
|
||||
const minFacts = clampInteger(
|
||||
input.extraction?.minFacts ?? DEFAULT_POLICY.extraction.minFacts,
|
||||
1,
|
||||
maxFacts,
|
||||
);
|
||||
|
||||
return {
|
||||
extraction: {
|
||||
updateEveryResponses: clampInteger(
|
||||
input.extraction?.updateEveryResponses ?? DEFAULT_POLICY.extraction.updateEveryResponses,
|
||||
1,
|
||||
500,
|
||||
),
|
||||
minFacts,
|
||||
maxFacts,
|
||||
recentMessagesWindow: clampInteger(
|
||||
input.extraction?.recentMessagesWindow ?? DEFAULT_POLICY.extraction.recentMessagesWindow,
|
||||
1,
|
||||
200,
|
||||
),
|
||||
},
|
||||
pruning: {
|
||||
workingFactsLimit,
|
||||
workingSourceMessagesLimit: clampInteger(
|
||||
input.pruning?.workingSourceMessagesLimit ?? DEFAULT_POLICY.pruning.workingSourceMessagesLimit,
|
||||
1,
|
||||
200,
|
||||
),
|
||||
archivalFactsLimit: clampInteger(
|
||||
input.pruning?.archivalFactsLimit ?? DEFAULT_POLICY.pruning.archivalFactsLimit,
|
||||
1,
|
||||
2_000,
|
||||
),
|
||||
archivalSummariesLimit: clampInteger(
|
||||
input.pruning?.archivalSummariesLimit ?? DEFAULT_POLICY.pruning.archivalSummariesLimit,
|
||||
1,
|
||||
500,
|
||||
),
|
||||
compatFactsLimit: clampInteger(
|
||||
input.pruning?.compatFactsLimit ?? DEFAULT_POLICY.pruning.compatFactsLimit,
|
||||
1,
|
||||
workingFactsLimit,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePersonaMemoryPolicy(): PersonaMemoryPolicy {
|
||||
return normalizePersonaMemoryPolicy({
|
||||
extraction: {
|
||||
updateEveryResponses: parseInteger(
|
||||
process.env.KXKM_PERSONA_MEMORY_UPDATE_EVERY,
|
||||
DEFAULT_POLICY.extraction.updateEveryResponses,
|
||||
),
|
||||
minFacts: parseInteger(
|
||||
process.env.KXKM_PERSONA_MEMORY_EXTRACTION_MIN_FACTS,
|
||||
DEFAULT_POLICY.extraction.minFacts,
|
||||
),
|
||||
maxFacts: parseInteger(
|
||||
process.env.KXKM_PERSONA_MEMORY_EXTRACTION_MAX_FACTS,
|
||||
DEFAULT_POLICY.extraction.maxFacts,
|
||||
),
|
||||
recentMessagesWindow: parseInteger(
|
||||
process.env.KXKM_PERSONA_MEMORY_EXTRACTION_WINDOW,
|
||||
DEFAULT_POLICY.extraction.recentMessagesWindow,
|
||||
),
|
||||
},
|
||||
pruning: {
|
||||
workingFactsLimit: parseInteger(
|
||||
process.env.KXKM_PERSONA_MEMORY_FACTS_LIMIT,
|
||||
DEFAULT_POLICY.pruning.workingFactsLimit,
|
||||
),
|
||||
workingSourceMessagesLimit: parseInteger(
|
||||
process.env.KXKM_PERSONA_MEMORY_SOURCE_MESSAGES_LIMIT,
|
||||
DEFAULT_POLICY.pruning.workingSourceMessagesLimit,
|
||||
),
|
||||
archivalFactsLimit: parseInteger(
|
||||
process.env.KXKM_PERSONA_MEMORY_ARCHIVAL_FACTS_LIMIT,
|
||||
DEFAULT_POLICY.pruning.archivalFactsLimit,
|
||||
),
|
||||
archivalSummariesLimit: parseInteger(
|
||||
process.env.KXKM_PERSONA_MEMORY_ARCHIVAL_SUMMARIES_LIMIT,
|
||||
DEFAULT_POLICY.pruning.archivalSummariesLimit,
|
||||
),
|
||||
compatFactsLimit: parseInteger(
|
||||
process.env.KXKM_PERSONA_MEMORY_COMPAT_FACTS_LIMIT,
|
||||
DEFAULT_POLICY.pruning.compatFactsLimit,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertArchivalFacts(
|
||||
existing: PersonaArchivalFact[],
|
||||
facts: string[],
|
||||
timestamp: string,
|
||||
limit: number,
|
||||
): PersonaArchivalFact[] {
|
||||
const byText = new Map<string, PersonaArchivalFact>();
|
||||
|
||||
for (const entry of existing) {
|
||||
const text = String(entry.text || "").trim();
|
||||
if (!text) continue;
|
||||
byText.set(text.toLowerCase(), { ...entry, text });
|
||||
}
|
||||
|
||||
for (const fact of facts) {
|
||||
const key = fact.toLowerCase();
|
||||
const current = byText.get(key);
|
||||
if (current) {
|
||||
current.lastSeenAt = timestamp;
|
||||
continue;
|
||||
}
|
||||
|
||||
byText.set(key, {
|
||||
text: fact,
|
||||
firstSeenAt: timestamp,
|
||||
lastSeenAt: timestamp,
|
||||
source: "chat",
|
||||
});
|
||||
}
|
||||
|
||||
return [...byText.values()].slice(-Math.max(1, limit));
|
||||
}
|
||||
|
||||
export function upsertArchivalSummaries(
|
||||
existing: PersonaArchivalSummary[],
|
||||
summary: string,
|
||||
timestamp: string,
|
||||
limit: number,
|
||||
): PersonaArchivalSummary[] {
|
||||
const trimmed = summary.trim();
|
||||
const next = cloneArchivalSummaries(existing).slice(-Math.max(1, limit));
|
||||
if (!trimmed) return next;
|
||||
const last = next[next.length - 1];
|
||||
if (last?.text === trimmed) return next;
|
||||
if (next.length >= limit) next.shift();
|
||||
next.push({ text: trimmed, createdAt: timestamp });
|
||||
return next;
|
||||
}
|
||||
|
||||
export function normalizePersonaMemory(
|
||||
memory: PersonaMemory,
|
||||
options: {
|
||||
policy?: PersonaMemoryPolicy;
|
||||
personaId?: string;
|
||||
recentMessages?: Iterable<unknown>;
|
||||
timestamp?: string;
|
||||
} = {},
|
||||
): PersonaMemory {
|
||||
const policy = options.policy ?? resolvePersonaMemoryPolicy();
|
||||
const timestamp = options.timestamp ?? new Date().toISOString();
|
||||
const workingFacts = trimUniqueStrings(
|
||||
memory.workingMemory?.facts?.length ? memory.workingMemory.facts : memory.facts,
|
||||
policy.pruning.workingFactsLimit,
|
||||
);
|
||||
const workingSummary = String(memory.workingMemory?.summary ?? memory.summary ?? "").trim();
|
||||
const lastSourceMessages = trimRecentMessages(
|
||||
options.recentMessages ?? memory.workingMemory?.lastSourceMessages ?? [],
|
||||
policy.pruning.workingSourceMessagesLimit,
|
||||
);
|
||||
const archivalFacts = upsertArchivalFacts(
|
||||
memory.archivalMemory?.facts || [],
|
||||
workingFacts,
|
||||
timestamp,
|
||||
policy.pruning.archivalFactsLimit,
|
||||
);
|
||||
const archivalSummaries = upsertArchivalSummaries(
|
||||
memory.archivalMemory?.summaries || [],
|
||||
workingSummary,
|
||||
timestamp,
|
||||
policy.pruning.archivalSummariesLimit,
|
||||
);
|
||||
|
||||
return {
|
||||
...memory,
|
||||
facts: [...workingFacts],
|
||||
summary: workingSummary,
|
||||
lastUpdated: timestamp,
|
||||
personaId: String(options.personaId ?? memory.personaId ?? "").trim() || undefined,
|
||||
version: 2,
|
||||
workingMemory: {
|
||||
facts: [...workingFacts],
|
||||
summary: workingSummary,
|
||||
lastSourceMessages,
|
||||
},
|
||||
archivalMemory: {
|
||||
facts: cloneArchivalFacts(archivalFacts),
|
||||
summaries: cloneArchivalSummaries(archivalSummaries),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyPersonaMemoryExtraction(
|
||||
memory: PersonaMemory,
|
||||
extracted: { facts?: unknown; summary?: unknown },
|
||||
options: {
|
||||
policy?: PersonaMemoryPolicy;
|
||||
personaId?: string;
|
||||
recentMessages?: Iterable<unknown>;
|
||||
timestamp?: string;
|
||||
} = {},
|
||||
): PersonaMemory {
|
||||
const policy = options.policy ?? resolvePersonaMemoryPolicy();
|
||||
const extractedFacts = trimUniqueStrings(
|
||||
Array.isArray(extracted.facts) ? extracted.facts : [],
|
||||
policy.extraction.maxFacts,
|
||||
);
|
||||
const mergedFacts = trimUniqueStrings(
|
||||
[...(memory.workingMemory?.facts || memory.facts || []), ...extractedFacts],
|
||||
policy.pruning.workingFactsLimit,
|
||||
);
|
||||
const nextSummary = typeof extracted.summary === "string" && extracted.summary.trim()
|
||||
? extracted.summary.trim()
|
||||
: String(memory.workingMemory?.summary ?? memory.summary ?? "").trim();
|
||||
|
||||
return normalizePersonaMemory(
|
||||
{
|
||||
...memory,
|
||||
personaId: options.personaId ?? memory.personaId,
|
||||
facts: mergedFacts,
|
||||
summary: nextSummary,
|
||||
workingMemory: {
|
||||
facts: mergedFacts,
|
||||
summary: nextSummary,
|
||||
lastSourceMessages: trimRecentMessages(
|
||||
options.recentMessages ?? memory.workingMemory?.lastSourceMessages ?? [],
|
||||
policy.pruning.workingSourceMessagesLimit,
|
||||
),
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPersonaMemoryExtractionPrompt(
|
||||
persona: Pick<ChatPersona, "nick">,
|
||||
recentMessages: Iterable<unknown>,
|
||||
policy: PersonaMemoryPolicy = resolvePersonaMemoryPolicy(),
|
||||
): string {
|
||||
const scopedMessages = trimRecentMessages(recentMessages, policy.extraction.recentMessagesWindow);
|
||||
return [
|
||||
`Tu es ${persona.nick}. Voici les derniers echanges:`,
|
||||
scopedMessages.join("\n"),
|
||||
"",
|
||||
`Extrais ${policy.extraction.minFacts}-${policy.extraction.maxFacts} faits importants a retenir sur l'utilisateur ou le sujet.`,
|
||||
"Resume ensuite l'etat de la conversation en une seule phrase concise.",
|
||||
'Reponds uniquement en JSON strict: {"facts":["fait1","fait2"],"summary":"resume"}',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function shouldUpdatePersonaMemory(
|
||||
responseCount: number,
|
||||
policy: PersonaMemoryPolicy = resolvePersonaMemoryPolicy(),
|
||||
): boolean {
|
||||
return responseCount > 0 && responseCount % policy.extraction.updateEveryResponses === 0;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resetPersonaMemory,
|
||||
savePersonaMemory,
|
||||
} from "./persona-memory-store.js";
|
||||
import { normalizePersonaMemoryPolicy } from "./persona-memory-policy.js";
|
||||
import type { PersonaMemory } from "./chat-types.js";
|
||||
|
||||
describe("persona-memory-store", () => {
|
||||
@@ -142,4 +143,53 @@ describe("persona-memory-store", () => {
|
||||
assert.deepEqual(legacyRecord.facts, []);
|
||||
assert.equal(legacyRecord.summary, "");
|
||||
});
|
||||
|
||||
it("applies configurable pruning limits when persisting v2 memory", async () => {
|
||||
const policy = normalizePersonaMemoryPolicy({
|
||||
pruning: {
|
||||
workingFactsLimit: 3,
|
||||
workingSourceMessagesLimit: 2,
|
||||
archivalFactsLimit: 4,
|
||||
archivalSummariesLimit: 2,
|
||||
compatFactsLimit: 2,
|
||||
},
|
||||
});
|
||||
|
||||
await savePersonaMemory({
|
||||
personaId: "schaeffer",
|
||||
nick: "Schaeffer",
|
||||
facts: ["fait 1", "fait 2", "fait 3", "fait 4"],
|
||||
summary: "resume courant",
|
||||
lastUpdated: "",
|
||||
version: 2,
|
||||
workingMemory: {
|
||||
facts: ["fait 1", "fait 2", "fait 3", "fait 4"],
|
||||
summary: "resume courant",
|
||||
lastSourceMessages: ["message 1", "message 2", "message 3"],
|
||||
},
|
||||
archivalMemory: {
|
||||
facts: [
|
||||
{ text: "archive 1", firstSeenAt: "2026-03-20T10:00:00.000Z", lastSeenAt: "2026-03-20T10:00:00.000Z", source: "chat" },
|
||||
{ text: "archive 2", firstSeenAt: "2026-03-20T10:00:00.000Z", lastSeenAt: "2026-03-20T10:00:00.000Z", source: "chat" },
|
||||
{ text: "archive 3", firstSeenAt: "2026-03-20T10:00:00.000Z", lastSeenAt: "2026-03-20T10:00:00.000Z", source: "chat" },
|
||||
],
|
||||
summaries: [
|
||||
{ text: "resume archive 1", createdAt: "2026-03-20T10:00:00.000Z" },
|
||||
{ text: "resume archive 2", createdAt: "2026-03-21T10:00:00.000Z" },
|
||||
],
|
||||
},
|
||||
}, policy);
|
||||
|
||||
const v2Record = JSON.parse(await readFile(path.join(localDir, "persona-memory", "schaeffer.json"), "utf8")) as {
|
||||
workingMemory: { facts: string[]; lastSourceMessages: string[] };
|
||||
archivalMemory: { facts: Array<{ text: string }>; summaries: Array<{ text: string }> };
|
||||
compat: { facts: string[] };
|
||||
};
|
||||
|
||||
assert.deepEqual(v2Record.workingMemory.facts, ["fait 2", "fait 3", "fait 4"]);
|
||||
assert.deepEqual(v2Record.workingMemory.lastSourceMessages, ["message 2", "message 3"]);
|
||||
assert.equal(v2Record.archivalMemory.facts.length, 4);
|
||||
assert.equal(v2Record.archivalMemory.summaries.length, 2);
|
||||
assert.deepEqual(v2Record.compat.facts, ["fait 3", "fait 4"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
ChatPersona,
|
||||
PersonaArchivalFact,
|
||||
PersonaArchivalMemory,
|
||||
PersonaArchivalSummary,
|
||||
PersonaMemory,
|
||||
PersonaWorkingMemory,
|
||||
} from "./chat-types.js";
|
||||
import {
|
||||
normalizePersonaMemory,
|
||||
type PersonaMemoryPolicy,
|
||||
resolvePersonaMemoryPolicy,
|
||||
} from "./persona-memory-policy.js";
|
||||
|
||||
type PersonaMemorySubject = string | Pick<ChatPersona, "id" | "nick"> | { id?: string; personaId?: string; nick: string };
|
||||
|
||||
@@ -16,8 +17,8 @@ interface PersonaMemoryRecordV2 {
|
||||
personaId: string;
|
||||
personaNick: string;
|
||||
updatedAt: string;
|
||||
workingMemory: PersonaWorkingMemory;
|
||||
archivalMemory: PersonaArchivalMemory;
|
||||
workingMemory: NonNullable<PersonaMemory["workingMemory"]>;
|
||||
archivalMemory: NonNullable<PersonaMemory["archivalMemory"]>;
|
||||
compat: {
|
||||
facts: string[];
|
||||
summary: string;
|
||||
@@ -83,26 +84,11 @@ async function writeJson(filePath: string, data: unknown): Promise<void> {
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function uniqueTrimmed(values: unknown[], limit: number): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const text = String(value || "").trim();
|
||||
const key = text.toLowerCase();
|
||||
if (!text || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(text);
|
||||
}
|
||||
|
||||
return result.slice(-limit);
|
||||
}
|
||||
|
||||
function cloneArchivalFacts(values: PersonaArchivalFact[]): PersonaArchivalFact[] {
|
||||
function cloneArchivalFacts(values: NonNullable<PersonaMemory["archivalMemory"]>["facts"]): NonNullable<PersonaMemory["archivalMemory"]>["facts"] {
|
||||
return values.map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
function cloneArchivalSummaries(values: PersonaArchivalSummary[]): PersonaArchivalSummary[] {
|
||||
function cloneArchivalSummaries(values: NonNullable<PersonaMemory["archivalMemory"]>["summaries"]): NonNullable<PersonaMemory["archivalMemory"]>["summaries"] {
|
||||
return values.map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
@@ -153,9 +139,9 @@ function createEmptyPersonaMemory(identity: { personaId?: string; nick: string }
|
||||
function toCompatMemory(record: PersonaMemoryRecordV2): PersonaMemory {
|
||||
return {
|
||||
nick: record.personaNick,
|
||||
facts: [...record.compat.facts],
|
||||
summary: record.compat.summary,
|
||||
lastUpdated: record.compat.lastUpdated || record.updatedAt,
|
||||
facts: [...record.workingMemory.facts],
|
||||
summary: record.workingMemory.summary,
|
||||
lastUpdated: record.updatedAt,
|
||||
personaId: record.personaId,
|
||||
version: 2,
|
||||
workingMemory: {
|
||||
@@ -170,71 +156,26 @@ function toCompatMemory(record: PersonaMemoryRecordV2): PersonaMemory {
|
||||
};
|
||||
}
|
||||
|
||||
function upsertArchivalFacts(existing: PersonaArchivalFact[], facts: string[], timestamp: string): PersonaArchivalFact[] {
|
||||
const byText = new Map<string, PersonaArchivalFact>();
|
||||
|
||||
for (const entry of existing) {
|
||||
const text = String(entry.text || "").trim();
|
||||
if (!text) continue;
|
||||
byText.set(text.toLowerCase(), { ...entry, text });
|
||||
}
|
||||
|
||||
for (const fact of facts) {
|
||||
const key = fact.toLowerCase();
|
||||
const current = byText.get(key);
|
||||
if (current) {
|
||||
current.lastSeenAt = timestamp;
|
||||
continue;
|
||||
}
|
||||
|
||||
byText.set(key, {
|
||||
text: fact,
|
||||
firstSeenAt: timestamp,
|
||||
lastSeenAt: timestamp,
|
||||
source: "chat",
|
||||
});
|
||||
}
|
||||
|
||||
return [...byText.values()].slice(-100);
|
||||
}
|
||||
|
||||
function upsertArchivalSummaries(existing: PersonaArchivalSummary[], summary: string, timestamp: string): PersonaArchivalSummary[] {
|
||||
const trimmed = summary.trim();
|
||||
const next = cloneArchivalSummaries(existing).slice(-49);
|
||||
if (!trimmed) return next;
|
||||
const last = next[next.length - 1];
|
||||
if (last?.text === trimmed) return next;
|
||||
next.push({ text: trimmed, createdAt: timestamp });
|
||||
return next;
|
||||
}
|
||||
|
||||
function toRecord(memory: PersonaMemory): PersonaMemoryRecordV2 {
|
||||
function toRecord(
|
||||
memory: PersonaMemory,
|
||||
policy: PersonaMemoryPolicy = resolvePersonaMemoryPolicy(),
|
||||
): PersonaMemoryRecordV2 {
|
||||
const timestamp = new Date().toISOString();
|
||||
const personaId = String(memory.personaId || memory.nick || "").trim();
|
||||
const nick = String(memory.nick || "").trim();
|
||||
const workingFacts = uniqueTrimmed(memory.workingMemory?.facts || memory.facts || [], 20);
|
||||
const workingSummary = String(memory.workingMemory?.summary ?? memory.summary ?? "").trim();
|
||||
const lastSourceMessages = uniqueTrimmed(memory.workingMemory?.lastSourceMessages || [], 10);
|
||||
const archivalFacts = upsertArchivalFacts(memory.archivalMemory?.facts || [], workingFacts, timestamp);
|
||||
const archivalSummaries = upsertArchivalSummaries(memory.archivalMemory?.summaries || [], workingSummary, timestamp);
|
||||
const normalized = normalizePersonaMemory(memory, { policy, timestamp });
|
||||
const personaId = String(normalized.personaId || normalized.nick || "").trim();
|
||||
const nick = String(normalized.nick || "").trim();
|
||||
const compatFacts = normalized.workingMemory!.facts.slice(-policy.pruning.compatFactsLimit);
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
personaId,
|
||||
personaNick: nick,
|
||||
updatedAt: timestamp,
|
||||
workingMemory: {
|
||||
facts: workingFacts,
|
||||
summary: workingSummary,
|
||||
lastSourceMessages,
|
||||
},
|
||||
archivalMemory: {
|
||||
facts: archivalFacts,
|
||||
summaries: archivalSummaries,
|
||||
},
|
||||
workingMemory: normalized.workingMemory!,
|
||||
archivalMemory: normalized.archivalMemory!,
|
||||
compat: {
|
||||
facts: workingFacts,
|
||||
summary: workingSummary,
|
||||
facts: compatFacts,
|
||||
summary: normalized.summary,
|
||||
lastUpdated: timestamp,
|
||||
},
|
||||
};
|
||||
@@ -245,31 +186,29 @@ function fromLegacyMemory(
|
||||
legacy: Partial<PersonaMemory>,
|
||||
): PersonaMemoryRecordV2 {
|
||||
const timestamp = String(legacy.lastUpdated || "").trim() || new Date().toISOString();
|
||||
const facts = uniqueTrimmed(Array.isArray(legacy.facts) ? legacy.facts : [], 20);
|
||||
const summary = String(legacy.summary || "").trim();
|
||||
const normalized = normalizePersonaMemory({
|
||||
nick: identity.nick,
|
||||
personaId: String(identity.personaId || identity.nick || "").trim(),
|
||||
facts: Array.isArray(legacy.facts) ? legacy.facts : [],
|
||||
summary: String(legacy.summary || "").trim(),
|
||||
lastUpdated: timestamp,
|
||||
}, {
|
||||
timestamp,
|
||||
personaId: String(identity.personaId || identity.nick || "").trim(),
|
||||
});
|
||||
const policy = resolvePersonaMemoryPolicy();
|
||||
const compatFacts = normalized.workingMemory!.facts.slice(-policy.pruning.compatFactsLimit);
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
personaId: String(identity.personaId || identity.nick || "").trim(),
|
||||
personaId: String(normalized.personaId || identity.personaId || identity.nick || "").trim(),
|
||||
personaNick: identity.nick,
|
||||
updatedAt: timestamp,
|
||||
workingMemory: {
|
||||
facts,
|
||||
summary,
|
||||
lastSourceMessages: [],
|
||||
},
|
||||
archivalMemory: {
|
||||
facts: facts.map((text) => ({
|
||||
text,
|
||||
firstSeenAt: timestamp,
|
||||
lastSeenAt: timestamp,
|
||||
source: "chat",
|
||||
})),
|
||||
summaries: summary ? [{ text: summary, createdAt: timestamp }] : [],
|
||||
},
|
||||
workingMemory: normalized.workingMemory!,
|
||||
archivalMemory: normalized.archivalMemory!,
|
||||
compat: {
|
||||
facts,
|
||||
summary,
|
||||
facts: compatFacts,
|
||||
summary: normalized.summary,
|
||||
lastUpdated: timestamp,
|
||||
},
|
||||
};
|
||||
@@ -350,9 +289,9 @@ export async function loadPersonaMemory(subject: PersonaMemorySubject): Promise<
|
||||
return clonePersonaMemory(fresh);
|
||||
}
|
||||
|
||||
export async function savePersonaMemory(memory: PersonaMemory): Promise<void> {
|
||||
export async function savePersonaMemory(memory: PersonaMemory, policy: PersonaMemoryPolicy = resolvePersonaMemoryPolicy()): Promise<void> {
|
||||
const identity = resolveIdentity({ personaId: memory.personaId, nick: memory.nick });
|
||||
const record = toRecord(memory);
|
||||
const record = toRecord(memory, policy);
|
||||
const { v2Dir, legacyDir } = resolveDirs();
|
||||
await writeJson(v2PathForId(v2Dir, record.personaId), record);
|
||||
await writeJson(legacyPathForNick(legacyDir, record.personaNick), {
|
||||
@@ -367,9 +306,12 @@ export async function savePersonaMemory(memory: PersonaMemory): Promise<void> {
|
||||
setCache(identity, compat);
|
||||
}
|
||||
|
||||
export async function resetPersonaMemory(subject: PersonaMemorySubject): Promise<PersonaMemory> {
|
||||
export async function resetPersonaMemory(
|
||||
subject: PersonaMemorySubject,
|
||||
policy: PersonaMemoryPolicy = resolvePersonaMemoryPolicy(),
|
||||
): Promise<PersonaMemory> {
|
||||
const identity = resolveIdentity(subject);
|
||||
const memory = createEmptyPersonaMemory(identity);
|
||||
await savePersonaMemory(memory);
|
||||
await savePersonaMemory(memory, policy);
|
||||
return loadPersonaMemory(identity);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ interface TestHarness {
|
||||
}
|
||||
|
||||
const originalTtsEnabled = process.env.TTS_ENABLED;
|
||||
const originalMemoryUpdateEvery = process.env.KXKM_PERSONA_MEMORY_UPDATE_EVERY;
|
||||
const originalMemorySourceLimit = process.env.KXKM_PERSONA_MEMORY_SOURCE_MESSAGES_LIMIT;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalTtsEnabled === undefined) {
|
||||
@@ -43,6 +45,18 @@ afterEach(() => {
|
||||
} else {
|
||||
process.env.TTS_ENABLED = originalTtsEnabled;
|
||||
}
|
||||
|
||||
if (originalMemoryUpdateEvery === undefined) {
|
||||
delete process.env.KXKM_PERSONA_MEMORY_UPDATE_EVERY;
|
||||
} else {
|
||||
process.env.KXKM_PERSONA_MEMORY_UPDATE_EVERY = originalMemoryUpdateEvery;
|
||||
}
|
||||
|
||||
if (originalMemorySourceLimit === undefined) {
|
||||
delete process.env.KXKM_PERSONA_MEMORY_SOURCE_MESSAGES_LIMIT;
|
||||
} else {
|
||||
process.env.KXKM_PERSONA_MEMORY_SOURCE_MESSAGES_LIMIT = originalMemorySourceLimit;
|
||||
}
|
||||
});
|
||||
|
||||
function sleep(ms = 0): Promise<void> {
|
||||
@@ -191,6 +205,88 @@ describe("ws-conversation-router", () => {
|
||||
assert.match(harness.memoryUpdates[0]?.recentMessages[4] || "", /message 5/);
|
||||
});
|
||||
|
||||
it("uses configurable memory cadence and source window limits", async () => {
|
||||
process.env.KXKM_PERSONA_MEMORY_UPDATE_EVERY = "3";
|
||||
process.env.KXKM_PERSONA_MEMORY_SOURCE_MESSAGES_LIMIT = "2";
|
||||
|
||||
const harness = createHarness();
|
||||
const routeToPersonas = createConversationRouter(harness.deps);
|
||||
|
||||
for (let index = 1; index <= 3; index += 1) {
|
||||
await routeToPersonas("#general", `memo ${index}`);
|
||||
}
|
||||
await sleep();
|
||||
|
||||
assert.equal(harness.memoryUpdates.length, 1);
|
||||
assert.deepEqual(
|
||||
harness.memoryUpdates[0]?.recentMessages.map((message) => message.replace(/^User:\s*/, "").split("\n")[0]),
|
||||
["memo 2", "memo 3"],
|
||||
);
|
||||
});
|
||||
|
||||
it("labels inter-persona rebounds distinctly in memory updates", async () => {
|
||||
process.env.KXKM_PERSONA_MEMORY_UPDATE_EVERY = "1";
|
||||
|
||||
const harness = createHarness({
|
||||
streamOllamaChat: async (_ollamaUrl, persona, message, _onChunk, onDone) => {
|
||||
harness.plainCalls.push({ persona, message });
|
||||
if (persona.nick === "Pharmacius") {
|
||||
onDone("Je passe la main. @Sherlock");
|
||||
return;
|
||||
}
|
||||
onDone("Je reprends la conversation.");
|
||||
},
|
||||
});
|
||||
const routeToPersonas = createConversationRouter(harness.deps);
|
||||
|
||||
await routeToPersonas("#general", "signal initial");
|
||||
await sleep();
|
||||
await sleep();
|
||||
|
||||
const pharmaciusUpdate = harness.memoryUpdates.find((entry) => entry.persona.nick === "Pharmacius");
|
||||
const sherlockUpdate = harness.memoryUpdates.find((entry) => entry.persona.nick === "Sherlock");
|
||||
|
||||
assert.match(pharmaciusUpdate?.recentMessages[0] || "", /^User: signal initial/);
|
||||
assert.match(sherlockUpdate?.recentMessages[0] || "", /^InterPersona: @Sherlock Pharmacius:/);
|
||||
});
|
||||
|
||||
it("invalidates cached persona memory after a background update", async () => {
|
||||
process.env.KXKM_PERSONA_MEMORY_UPDATE_EVERY = "1";
|
||||
|
||||
let currentMemory = {
|
||||
nick: "Pharmacius",
|
||||
personaId: "pharmacius",
|
||||
facts: [] as string[],
|
||||
summary: "",
|
||||
lastUpdated: "",
|
||||
};
|
||||
|
||||
const harness = createHarness({
|
||||
loadPersonaMemory: async () => ({ ...currentMemory, facts: [...currentMemory.facts] }),
|
||||
updatePersonaMemory: async () => {
|
||||
currentMemory = {
|
||||
...currentMemory,
|
||||
facts: ["memo fraiche"],
|
||||
summary: "resume mis a jour",
|
||||
lastUpdated: "2026-03-25T23:00:00.000Z",
|
||||
};
|
||||
},
|
||||
streamOllamaChat: async (_ollamaUrl, persona, message, _onChunk, onDone) => {
|
||||
harness.plainCalls.push({ persona, message });
|
||||
onDone(`Reponse ${persona.nick}`);
|
||||
},
|
||||
});
|
||||
const routeToPersonas = createConversationRouter(harness.deps);
|
||||
|
||||
await routeToPersonas("#general", "premier message");
|
||||
await sleep();
|
||||
await routeToPersonas("#general", "second message");
|
||||
|
||||
assert.equal(harness.plainCalls.length, 2);
|
||||
assert.match(harness.plainCalls[1]?.persona.systemPrompt || "", /\[Mémoire\]/);
|
||||
assert.match(harness.plainCalls[1]?.persona.systemPrompt || "", /memo fraiche/);
|
||||
});
|
||||
|
||||
it("triggers TTS only when the feature flag is enabled", async () => {
|
||||
process.env.TTS_ENABLED = "0";
|
||||
const disabledHarness = createHarness();
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
updatePersonaMemory as defaultUpdatePersonaMemory,
|
||||
pickResponders as defaultPickResponders,
|
||||
} from "./ws-persona-router.js";
|
||||
import { resolvePersonaMemoryPolicy, shouldUpdatePersonaMemory } from "./persona-memory-policy.js";
|
||||
import type { ChatLogEntry, ChatPersona, OutboundMessage, PersonaMemory } from "./chat-types.js";
|
||||
|
||||
const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1";
|
||||
@@ -120,8 +121,10 @@ function getPersonaMood(): string {
|
||||
|
||||
function withPersonaMemory(persona: ChatPersona, memory: Awaited<ReturnType<LoadPersonaMemoryFn>>): ChatPersona {
|
||||
const mood = getPersonaMood();
|
||||
const retainedFacts = memory.workingMemory?.facts || memory.facts;
|
||||
const retainedSummary = memory.workingMemory?.summary || memory.summary;
|
||||
|
||||
if (memory.facts.length === 0 && !memory.summary) {
|
||||
if (retainedFacts.length === 0 && !retainedSummary) {
|
||||
return {
|
||||
...persona,
|
||||
systemPrompt: persona.systemPrompt + `\n\n[Humeur] ${mood}`,
|
||||
@@ -130,8 +133,8 @@ function withPersonaMemory(persona: ChatPersona, memory: Awaited<ReturnType<Load
|
||||
|
||||
const memoryBlock = [
|
||||
"\n\n[Mémoire]",
|
||||
memory.facts.length > 0 ? `Faits retenus: ${memory.facts.join(", ")}` : "",
|
||||
memory.summary ? `Résumé: ${memory.summary}` : "",
|
||||
retainedFacts.length > 0 ? `Faits retenus: ${retainedFacts.join(", ")}` : "",
|
||||
retainedSummary ? `Résumé: ${retainedSummary}` : "",
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
return {
|
||||
@@ -246,6 +249,7 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
|
||||
const getMaxResponders = typeof maxGeneralRespondersOpt === "function"
|
||||
? maxGeneralRespondersOpt
|
||||
: () => maxGeneralRespondersOpt;
|
||||
const memoryPolicy = resolvePersonaMemoryPolicy();
|
||||
|
||||
const personaCooldowns = new Map<string, number>();
|
||||
const personaMessageCounts = new Map<string, number>();
|
||||
@@ -257,8 +261,13 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
|
||||
// Memory cache (30s TTL) to avoid N+1 reloads in inter-persona chains
|
||||
const memoryCache = new Map<string, { data: PersonaMemory; ts: number }>();
|
||||
const MEMORY_CACHE_TTL = 30_000;
|
||||
|
||||
function getMemoryCacheKey(persona: Pick<ChatPersona, "id" | "nick">): string {
|
||||
return persona.id || persona.nick;
|
||||
}
|
||||
|
||||
async function cachedLoadMemory(persona: ChatPersona): Promise<PersonaMemory> {
|
||||
const cacheKey = persona.id || persona.nick;
|
||||
const cacheKey = getMemoryCacheKey(persona);
|
||||
const cached = memoryCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.ts < MEMORY_CACHE_TTL) return cached.data;
|
||||
const data = await loadPersonaMemory(persona);
|
||||
@@ -270,6 +279,10 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
|
||||
return data;
|
||||
}
|
||||
|
||||
function invalidateCachedMemory(persona: Pick<ChatPersona, "id" | "nick">): void {
|
||||
memoryCache.delete(getMemoryCacheKey(persona));
|
||||
}
|
||||
|
||||
function enqueueTTS(nick: string, text: string, channel: string): void {
|
||||
if (process.env.TTS_ENABLED !== "1" || !isTTSAvailable()) return;
|
||||
if (text.length < 10) return;
|
||||
@@ -286,6 +299,7 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
|
||||
|
||||
function prunePersonaState(personas: ChatPersona[]): void {
|
||||
const activeNicks = new Set(personas.map((persona) => persona.nick));
|
||||
const activeMemoryKeys = new Set(personas.map((persona) => getMemoryCacheKey(persona)));
|
||||
for (const [nick] of personaMessageCounts) {
|
||||
if (!activeNicks.has(nick)) {
|
||||
personaMessageCounts.delete(nick);
|
||||
@@ -293,6 +307,11 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
|
||||
personaMemoryLocks.delete(nick);
|
||||
}
|
||||
}
|
||||
for (const [cacheKey] of memoryCache) {
|
||||
if (!activeMemoryKeys.has(cacheKey)) {
|
||||
memoryCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function trackPersonaMessage(nick: string, text: string): { count: number; recentMessages: string[] } {
|
||||
@@ -301,7 +320,7 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
|
||||
|
||||
const recentMessages = personaRecentMessages.get(nick) || [];
|
||||
recentMessages.push(text);
|
||||
if (recentMessages.length > 10) {
|
||||
if (recentMessages.length > memoryPolicy.pruning.workingSourceMessagesLimit) {
|
||||
recentMessages.shift();
|
||||
}
|
||||
personaRecentMessages.set(nick, recentMessages);
|
||||
@@ -311,7 +330,10 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
|
||||
function scheduleMemoryUpdate(persona: ChatPersona, recentMessages: string[]): void {
|
||||
const previous = personaMemoryLocks.get(persona.nick) || Promise.resolve();
|
||||
const next = previous
|
||||
.then(() => updatePersonaMemory(persona, recentMessages, ollamaUrl))
|
||||
.then(async () => {
|
||||
await updatePersonaMemory(persona, recentMessages, ollamaUrl);
|
||||
invalidateCachedMemory(persona);
|
||||
})
|
||||
.catch((err) => {
|
||||
trackError("memory_update", err, { persona: persona.nick });
|
||||
});
|
||||
@@ -456,11 +478,12 @@ export function createConversationRouter(deps: ConversationRouterDeps): Conversa
|
||||
}
|
||||
}
|
||||
|
||||
const sourceLabel = depth > 0 ? "InterPersona" : "User";
|
||||
const { count, recentMessages } = trackPersonaMessage(
|
||||
persona.nick,
|
||||
`User: ${text}\n${persona.nick}: ${fullText}`,
|
||||
`${sourceLabel}: ${text}\n${persona.nick}: ${fullText}`,
|
||||
);
|
||||
if (count > 0 && count % 5 === 0) {
|
||||
if (shouldUpdatePersonaMemory(count, memoryPolicy)) {
|
||||
scheduleMemoryUpdate(persona, recentMessages);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import logger from "./logger.js";
|
||||
import type { ChatPersona } from "./chat-types.js";
|
||||
import {
|
||||
applyPersonaMemoryExtraction,
|
||||
buildPersonaMemoryExtractionPrompt,
|
||||
resolvePersonaMemoryPolicy,
|
||||
} from "./persona-memory-policy.js";
|
||||
import {
|
||||
loadPersonaMemory,
|
||||
resetPersonaMemory,
|
||||
@@ -17,11 +22,8 @@ export async function updatePersonaMemory(
|
||||
ollamaUrl: string,
|
||||
): Promise<void> {
|
||||
const memory = await loadPersonaMemory(persona);
|
||||
|
||||
const prompt =
|
||||
`Tu es ${persona.nick}. Voici les derniers échanges:\n${recentMessages.join("\n")}\n\n` +
|
||||
`Extrais 2-3 faits importants à retenir sur l'utilisateur ou le sujet. ` +
|
||||
`Réponds en JSON: {"facts": ["fait1", "fait2"], "summary": "résumé en une phrase"}`;
|
||||
const policy = resolvePersonaMemoryPolicy();
|
||||
const prompt = buildPersonaMemoryExtractionPrompt(persona, recentMessages, policy);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ollamaUrl}/api/chat`, {
|
||||
@@ -36,31 +38,32 @@ export async function updatePersonaMemory(
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Memory update HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { message?: { content?: string } };
|
||||
let extracted: { facts?: string[]; summary?: string } = {};
|
||||
const rawContent = String(data.message?.content || "").trim();
|
||||
if (!rawContent) {
|
||||
logger.error({ nick: persona.nick }, "[persona-router] Empty LLM JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
let extracted: { facts?: string[]; summary?: string };
|
||||
try {
|
||||
extracted = JSON.parse(data.message?.content || "{}");
|
||||
extracted = JSON.parse(rawContent) as { facts?: string[]; summary?: string };
|
||||
} catch (parseErr) {
|
||||
logger.error({ err: parseErr }, "[persona-router] Failed to parse LLM JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
if (extracted.facts && Array.isArray(extracted.facts)) {
|
||||
const allFacts = [...new Set([...memory.facts, ...extracted.facts])].slice(-20);
|
||||
memory.facts = allFacts;
|
||||
}
|
||||
if (extracted.summary) {
|
||||
memory.summary = extracted.summary;
|
||||
}
|
||||
const updated = applyPersonaMemoryExtraction(memory, extracted, {
|
||||
policy,
|
||||
personaId: persona.id,
|
||||
recentMessages,
|
||||
});
|
||||
|
||||
memory.personaId = persona.id;
|
||||
memory.version = 2;
|
||||
memory.workingMemory = {
|
||||
facts: [...memory.facts],
|
||||
summary: memory.summary,
|
||||
lastSourceMessages: recentMessages.slice(-10),
|
||||
};
|
||||
|
||||
await savePersonaMemory(memory);
|
||||
await savePersonaMemory(updated, policy);
|
||||
} catch (err) {
|
||||
logger.error({ err: err instanceof Error ? err.message : String(err), nick: persona.nick }, "[ws-chat] Memory update failed");
|
||||
}
|
||||
|
||||
+3
-2
@@ -644,9 +644,10 @@ Chaque persona accumule des faits et un resume sur ses interactions. La source d
|
||||
|
||||
### Memoire — Mise a jour
|
||||
|
||||
- Toutes les 5 interactions, la persona recoit ses 10 derniers echanges et genere un JSON de faits + resume via Ollama
|
||||
- Une policy centralisee pilote la cadence, la fenetre d extraction et les caps de pruning; par defaut la mise a jour part toutes les 5 interactions sur les 10 derniers echanges
|
||||
- Le store charge d'abord le fichier V2 par `personaId`, puis migre automatiquement l'ancien fichier legacy par `nick` s'il est encore seul present
|
||||
- Les faits de travail sont dedupliques et limites a 20 max; l'archive est normalisee a 100 faits et 50 resumes
|
||||
- Les faits de travail sont dedupliques et limites a 20 max; l'archive est normalisee a 100 faits et 50 resumes; le miroir `compat` reste borne a 20 faits
|
||||
- Les overrides runtime passent par `KXKM_PERSONA_MEMORY_UPDATE_EVERY`, `KXKM_PERSONA_MEMORY_EXTRACTION_{MIN,MAX}_FACTS`, `KXKM_PERSONA_MEMORY_EXTRACTION_WINDOW`, `KXKM_PERSONA_MEMORY_{FACTS,SOURCE_MESSAGES,ARCHIVAL_FACTS,ARCHIVAL_SUMMARIES,COMPAT_FACTS}_LIMIT`
|
||||
- La memoire est injectee dans le systemPrompt sous forme de bloc `[Memoire]`
|
||||
|
||||
## 12. Flux principal (mermaid)
|
||||
|
||||
+1
-1
@@ -429,7 +429,7 @@ Complete table of all environment variables used across docker-compose.yml, Dock
|
||||
|
||||
- **Format:** JSON files at `data/v2-local/persona-memory/*.json` with optional legacy mirror `data/persona-memory/*.json`
|
||||
- **Threshold:** Files exceeding 100 KB (102,400 bytes) are trimmed
|
||||
- **Strategy:** Keep runtime caps only (`workingMemory.facts[-20:]`, `workingMemory.lastSourceMessages[-10:]`, `archivalMemory.facts[-100:]`, `archivalMemory.summaries[-50:]`, `compat.facts[-20:]`)
|
||||
- **Strategy:** Keep runtime caps only (`workingMemory.facts[-20:]`, `workingMemory.lastSourceMessages[-10:]`, `archivalMemory.facts[-100:]`, `archivalMemory.summaries[-50:]`, `compat.facts[-20:]`) unless overridden by `KXKM_PERSONA_MEMORY_*`
|
||||
- **Schedule:** Same daily timer as chat log cleanup
|
||||
|
||||
### Context Store (Auto-Compaction)
|
||||
|
||||
+49
-20
@@ -1,7 +1,7 @@
|
||||
# SPEC_PERSONAS.md — Persona System, Routing & Memory
|
||||
|
||||
> Version: 1.0 — 2026-03-20
|
||||
> Source files: `apps/api/src/personas-default.ts`, `ws-persona-router.ts`, `ws-conversation-router.ts`, `persona-voices.ts`, `mcp-tools.ts`, `ws-ollama.ts`, `chat-types.ts`
|
||||
> Source files: `apps/api/src/personas-default.ts`, `ws-persona-router.ts`, `ws-conversation-router.ts`, `persona-memory-store.ts`, `persona-memory-policy.ts`, `persona-voices.ts`, `mcp-tools.ts`, `ws-ollama.ts`, `chat-types.ts`
|
||||
|
||||
---
|
||||
|
||||
@@ -211,11 +211,36 @@ Each persona maintains a persistent memory file that evolves over the course of
|
||||
### Data Model (`PersonaMemory`)
|
||||
|
||||
```typescript
|
||||
interface PersonaWorkingMemory {
|
||||
facts: string[];
|
||||
summary: string;
|
||||
lastSourceMessages: string[];
|
||||
}
|
||||
|
||||
interface PersonaArchivalFact {
|
||||
text: string;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
source: "chat";
|
||||
}
|
||||
|
||||
interface PersonaArchivalSummary {
|
||||
text: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PersonaMemory {
|
||||
nick: string; // persona identifier
|
||||
facts: string[]; // up to 20 retained facts
|
||||
summary: string; // one-sentence summary of recent interactions
|
||||
lastUpdated: string; // ISO 8601 timestamp
|
||||
nick: string;
|
||||
facts: string[]; // compat projection for legacy consumers
|
||||
summary: string; // compat projection for legacy consumers
|
||||
lastUpdated: string;
|
||||
personaId?: string;
|
||||
version?: 2;
|
||||
workingMemory?: PersonaWorkingMemory;
|
||||
archivalMemory?: {
|
||||
facts: PersonaArchivalFact[];
|
||||
summaries: PersonaArchivalSummary[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
@@ -228,33 +253,37 @@ interface PersonaMemory {
|
||||
|
||||
### Loading (`loadPersonaMemory`)
|
||||
|
||||
Called at the start of every `streamPersonaResponse`. The loader resolves by `personaId` first, falls back to case-insensitive `nick`, and auto-migrates a legacy-only file into the V2 store on first read. It returns empty memory `{ nick, facts: [], summary: "", lastUpdated: "" }` if no record exists or if the file is corrupted. Errors are caught and tracked via `error-tracker`.
|
||||
Called at the start of every `streamPersonaResponse`. The loader resolves by `personaId` first, falls back to case-insensitive `nick`, and auto-migrates a legacy-only file into the V2 store on first read. It returns empty memory `{ nick, facts: [], summary: "", lastUpdated: "" }` if no record exists or if a stored payload is unusable; hard failures surfaced at router level are tracked via `error-tracker`.
|
||||
|
||||
### Update Cycle (`updatePersonaMemory`)
|
||||
|
||||
**Trigger:** Every 5 interactions per persona (tracked via `personaMessageCounts` map).
|
||||
**Trigger:** Controlled by the memory policy engine. Default: every 5 interactions per persona (tracked via `personaMessageCounts` map).
|
||||
|
||||
```
|
||||
count = personaMessageCounts[nick] + 1
|
||||
if (count > 0 && count % 5 === 0) -> scheduleMemoryUpdate()
|
||||
if (count > 0 && count % updateEveryResponses === 0) -> scheduleMemoryUpdate()
|
||||
```
|
||||
|
||||
**Process:**
|
||||
1. Load current memory from disk
|
||||
2. Send recent messages (up to 10, tracked in `personaRecentMessages`) to the persona's own LLM model
|
||||
3. Prompt: Extract 2-3 important facts + one-sentence summary, respond in JSON
|
||||
4. Merge: deduplicate facts via `Set`, keep last 20 facts (`slice(-20)`)
|
||||
5. Overwrite summary with the new one
|
||||
6. Save to disk with updated timestamp
|
||||
2. Send the recent message window configured by policy (default: 10, tracked in `personaRecentMessages`) to the persona's own LLM model
|
||||
3. Prompt: ask for `minFacts-maxFacts` important facts + one-sentence summary, respond in JSON
|
||||
4. Merge: deduplicate facts, apply pruning caps from the shared memory policy, then rebuild archival + compat projections
|
||||
5. Save to disk with updated timestamp
|
||||
|
||||
**LLM extraction prompt:**
|
||||
```
|
||||
Tu es {Nick}. Voici les derniers echanges:
|
||||
{recentMessages joined by \n}
|
||||
**Default runtime policy (override via `KXKM_PERSONA_MEMORY_*`):**
|
||||
- `updateEveryResponses=5`
|
||||
- `extraction.minFacts=2`
|
||||
- `extraction.maxFacts=3`
|
||||
- `extraction.recentMessagesWindow=10`
|
||||
- `pruning.workingFactsLimit=20`
|
||||
- `pruning.workingSourceMessagesLimit=10`
|
||||
- `pruning.archivalFactsLimit=100`
|
||||
- `pruning.archivalSummariesLimit=50`
|
||||
- `pruning.compatFactsLimit=20`
|
||||
|
||||
Extrais 2-3 faits importants a retenir sur l'utilisateur ou le sujet.
|
||||
Reponds en JSON: {"facts": ["fait1", "fait2"], "summary": "resume en une phrase"}
|
||||
```
|
||||
The extraction prompt asks the persona model to respond in JSON with:
|
||||
`{"facts":["fait1","fait2"],"summary":"resume en une phrase"}`.
|
||||
|
||||
**Constraints:**
|
||||
- 30-second timeout (`AbortSignal.timeout(30_000)`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PLAN (kxkm-personas-runtime-20260325)
|
||||
|
||||
Updated: 2026-03-25T20:16:12Z
|
||||
Updated: 2026-03-26T06:58:28Z
|
||||
|
||||
## lot-201-runtime-hardening [done]
|
||||
- Description: Durcir le runtime personas local et basculer le store actif en per-file v2-local
|
||||
@@ -18,13 +18,13 @@ Updated: 2026-03-25T20:16:12Z
|
||||
- Checks: npm run check, npm run test:v2
|
||||
- Summary: Done: store partage `persona-memory-store`, migration auto V1 -> V2 par personaId, miroir legacy de compat, commandes runtime basculees et tests cibles ajoutes.
|
||||
|
||||
## lot-203-memory-policy [pending]
|
||||
## lot-203-memory-policy [in_progress]
|
||||
- Description: Ajouter un moteur de policies pour extraction, summarization, pruning et evaluation memory
|
||||
- Depends on: lot-202-memory-schema
|
||||
- Owner: Backend API
|
||||
- Execution: manual
|
||||
- Checks: npm run check, npm run test:v2
|
||||
- Summary: Pending.
|
||||
- Summary: In progress: module partage `persona-memory-policy`, caps et cadence configurables, integration store + WS runtime, correctifs audit sur cache et rebounds inter-persona, tests cibles et suite V2 OK; telemetry et eval-harness restent pending.
|
||||
|
||||
## lot-204-oss-benchmark [in_progress]
|
||||
- Description: Comparer patterns Letta LangGraph Mem0 et preparer un spike OpenCharacter PCL
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# EXECUTION STATUS (kxkm-personas-runtime-20260325)
|
||||
|
||||
Updated: 2026-03-25T20:16:12Z
|
||||
Updated: 2026-03-26T06:58:28Z
|
||||
|
||||
## lot-201-runtime-hardening
|
||||
- Status: done
|
||||
@@ -17,12 +17,12 @@ Updated: 2026-03-25T20:16:12Z
|
||||
- Open tasks: none
|
||||
|
||||
## lot-203-memory-policy
|
||||
- Status: pending
|
||||
- Status: in_progress
|
||||
- Owner: Backend API
|
||||
- Execution: manual
|
||||
- Checks: npm run check, npm run test:v2
|
||||
- Open tasks:
|
||||
- policy-engine [pending] (P1, Backend API)
|
||||
- Tasks:
|
||||
- policy-engine [done] (P1, Backend API)
|
||||
- telemetry [pending] (P2, Ops/TUI)
|
||||
- eval-harness [pending] (P2, Personas)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# TODO (kxkm-personas-runtime-20260325)
|
||||
|
||||
Updated: 2026-03-25T20:16:12Z
|
||||
Updated: 2026-03-26T06:58:28Z
|
||||
|
||||
## lot-201-runtime-hardening
|
||||
- [x] per-file-store (done) | owner: Personas | severity: P1
|
||||
@@ -17,7 +17,7 @@ Updated: 2026-03-25T20:16:12Z
|
||||
- [x] migration-soft (done) | owner: Coordinateur | severity: P2
|
||||
|
||||
## lot-203-memory-policy
|
||||
- [ ] policy-engine (pending) | owner: Backend API | severity: P1
|
||||
- [x] policy-engine (done) | owner: Backend API | severity: P1
|
||||
- [ ] telemetry (pending) | owner: Ops/TUI | severity: P2
|
||||
- [ ] eval-harness (pending) | owner: Personas | severity: P2
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# lot-203-memory-policy
|
||||
|
||||
- Status: in_progress
|
||||
- Date: 2026-03-25
|
||||
- Scope: introduire un moteur de policy partage pour l extraction, la cadence d update et le pruning de la memoire personas V2
|
||||
|
||||
## Actions
|
||||
|
||||
- ajout de `apps/api/src/persona-memory-policy.ts`
|
||||
- centralisation des caps runtime et des overrides `KXKM_PERSONA_MEMORY_*`
|
||||
- integration de la policy dans `persona-memory-store.ts`, `ws-persona-router.ts` et `ws-conversation-router.ts`
|
||||
- ajout de tests cibles sur la policy, le store et le routeur conversationnel
|
||||
- correction de l invalidation du cache memoire apres mise a jour en arriere-plan
|
||||
- correction du marquage des rebounds inter-persona pour ne plus les serialiser comme des messages user
|
||||
- alignement de `scripts/cleanup-logs.sh` sur les overrides runtime de la policy memoire
|
||||
|
||||
## Reste a faire
|
||||
|
||||
- telemetry memory drift / recall / write-rate
|
||||
- eval harness de coherence memory
|
||||
|
||||
## Validation
|
||||
|
||||
- `npm run check`
|
||||
- `npm run test:v2`
|
||||
@@ -40,14 +40,14 @@
|
||||
{
|
||||
"id": "lot-203-memory-policy",
|
||||
"description": "Ajouter un moteur de policies pour extraction, summarization, pruning et evaluation memory",
|
||||
"summary": "Pending.",
|
||||
"status": "pending",
|
||||
"summary": "In progress: policy-engine partage livre, runtime WS + store relies sur une policy configurable, cache et rebounds inter-persona durcis; telemetry et eval-harness restent a faire.",
|
||||
"status": "in_progress",
|
||||
"owner": "Backend API",
|
||||
"execution": "manual",
|
||||
"checks": ["npm run check", "npm run test:v2"],
|
||||
"depends_on": ["lot-202-memory-schema"],
|
||||
"tasks": [
|
||||
{ "id": "policy-engine", "query": "Implementer des policies configurables extraction summarize prune", "status": "pending", "owner": "Backend API", "severity": "P1" },
|
||||
{ "id": "policy-engine", "query": "Implementer des policies configurables extraction summarize prune", "status": "done", "owner": "Backend API", "severity": "P1" },
|
||||
{ "id": "telemetry", "query": "Mesurer drift, recall et write-rate des memories personas", "status": "pending", "owner": "Ops/TUI", "severity": "P2" },
|
||||
{ "id": "eval-harness", "query": "Construire un harness de non-regression pour coherence memory", "status": "pending", "owner": "Personas", "severity": "P2" }
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"project": "kxkm-personas-runtime-20260325",
|
||||
"updated_at": "2026-03-25T20:16:12Z",
|
||||
"updated_at": "2026-03-26T06:58:28Z",
|
||||
"batches": {
|
||||
"lot-201-runtime-hardening": {
|
||||
"status": "done",
|
||||
@@ -79,16 +79,16 @@
|
||||
}
|
||||
},
|
||||
"lot-203-memory-policy": {
|
||||
"status": "pending",
|
||||
"status": "in_progress",
|
||||
"depends_on": [
|
||||
"lot-202-memory-schema"
|
||||
],
|
||||
"owner": "Backend API",
|
||||
"tasks": {
|
||||
"policy-engine": {
|
||||
"status": "pending",
|
||||
"attempts": 0,
|
||||
"output": "",
|
||||
"status": "done",
|
||||
"attempts": 1,
|
||||
"output": "ops/v2/personas-runtime-20260325/outputs/lot-203-memory-policy/summary.md",
|
||||
"last_error": ""
|
||||
},
|
||||
"telemetry": {
|
||||
|
||||
+13
-6
@@ -15,6 +15,13 @@ resolve_repo_path() {
|
||||
LOCAL_DATA_ROOT=$(resolve_repo_path "${KXKM_LOCAL_DATA_DIR:-data/v2-local}")
|
||||
PERSONA_MEMORY_DIR="$LOCAL_DATA_ROOT/persona-memory"
|
||||
LEGACY_MEMORY_DIR=$(resolve_repo_path "${KXKM_PERSONA_MEMORY_LEGACY_DIR:-data/persona-memory}")
|
||||
MEMORY_FACTS_LIMIT=${KXKM_PERSONA_MEMORY_FACTS_LIMIT:-20}
|
||||
MEMORY_SOURCE_MESSAGES_LIMIT=${KXKM_PERSONA_MEMORY_SOURCE_MESSAGES_LIMIT:-10}
|
||||
MEMORY_ARCHIVAL_FACTS_LIMIT=${KXKM_PERSONA_MEMORY_ARCHIVAL_FACTS_LIMIT:-100}
|
||||
MEMORY_ARCHIVAL_SUMMARIES_LIMIT=${KXKM_PERSONA_MEMORY_ARCHIVAL_SUMMARIES_LIMIT:-50}
|
||||
MEMORY_COMPAT_FACTS_LIMIT=${KXKM_PERSONA_MEMORY_COMPAT_FACTS_LIMIT:-20}
|
||||
export MEMORY_FACTS_LIMIT MEMORY_SOURCE_MESSAGES_LIMIT MEMORY_ARCHIVAL_FACTS_LIMIT
|
||||
export MEMORY_ARCHIVAL_SUMMARIES_LIMIT MEMORY_COMPAT_FACTS_LIMIT
|
||||
|
||||
trim_persona_memory_dir() {
|
||||
local dir="$1"
|
||||
@@ -38,19 +45,19 @@ with file_path.open("r", encoding="utf-8") as fh:
|
||||
|
||||
if isinstance(data.get("workingMemory"), dict):
|
||||
working = data["workingMemory"]
|
||||
working["facts"] = list(working.get("facts") or [])[-20:]
|
||||
working["lastSourceMessages"] = list(working.get("lastSourceMessages") or [])[-10:]
|
||||
working["facts"] = list(working.get("facts") or [])[-int(os.environ.get("MEMORY_FACTS_LIMIT", "20")):]
|
||||
working["lastSourceMessages"] = list(working.get("lastSourceMessages") or [])[-int(os.environ.get("MEMORY_SOURCE_MESSAGES_LIMIT", "10")):]
|
||||
|
||||
if isinstance(data.get("archivalMemory"), dict):
|
||||
archival = data["archivalMemory"]
|
||||
archival["facts"] = list(archival.get("facts") or [])[-100:]
|
||||
archival["summaries"] = list(archival.get("summaries") or [])[-50:]
|
||||
archival["facts"] = list(archival.get("facts") or [])[-int(os.environ.get("MEMORY_ARCHIVAL_FACTS_LIMIT", "100")):]
|
||||
archival["summaries"] = list(archival.get("summaries") or [])[-int(os.environ.get("MEMORY_ARCHIVAL_SUMMARIES_LIMIT", "50")):]
|
||||
|
||||
if isinstance(data.get("compat"), dict):
|
||||
compat = data["compat"]
|
||||
compat["facts"] = list(compat.get("facts") or [])[-20:]
|
||||
compat["facts"] = list(compat.get("facts") or [])[-int(os.environ.get("MEMORY_COMPAT_FACTS_LIMIT", "20")):]
|
||||
else:
|
||||
data["facts"] = list(data.get("facts") or [])[-20:]
|
||||
data["facts"] = list(data.get("facts") or [])[-int(os.environ.get("MEMORY_COMPAT_FACTS_LIMIT", "20")):]
|
||||
|
||||
with file_path.open("w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2)
|
||||
|
||||
Reference in New Issue
Block a user