feat(personas): add configurable memory policy engine

This commit is contained in:
Codex Local
2026-03-26 08:20:00 +01:00
parent 2fcac5ff1c
commit 267ae228be
17 changed files with 804 additions and 183 deletions
@@ -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);
});
});
+356
View File
@@ -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;
}
+50
View File
@@ -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"]);
});
});
+48 -106
View File
@@ -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();
+31 -8
View File
@@ -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);
}
+26 -23
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)`)
+3 -3
View File
@@ -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
+4 -4
View File
@@ -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)
+2 -2
View File
@@ -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" }
]
+5 -5
View File
@@ -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
View File
@@ -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)