Skip to content

Commit cbe058a

Browse files
committed
feat(emergent): add PersonalityMutationStore with SQLite persistence and decay
1 parent f7a75ae commit cbe058a

2 files changed

Lines changed: 481 additions & 0 deletions

File tree

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
/**
2+
* @fileoverview SQLite persistence for personality mutations with
3+
* Ebbinghaus-style strength decay.
4+
*
5+
* Mutations persist across sessions and gradually fade toward baseline
6+
* unless reinforced by repeated adaptation. The ConsolidationLoop calls
7+
* {@link PersonalityMutationStore.decayAll} each cycle to reduce mutation
8+
* strengths; mutations whose strength drops below the 0.1 threshold are
9+
* pruned automatically.
10+
*
11+
* Uses the same {@link IStorageAdapter} interface as EmergentToolRegistry,
12+
* keeping storage concerns decoupled from specific SQLite drivers.
13+
*
14+
* @module @framers/agentos/emergent/PersonalityMutationStore
15+
*/
16+
17+
import type { IStorageAdapter } from './EmergentToolRegistry.js';
18+
19+
// ============================================================================
20+
// TYPES
21+
// ============================================================================
22+
23+
/**
24+
* A single persisted personality mutation record.
25+
*
26+
* Represents a specific HEXACO trait adjustment made by the agent, along with
27+
* its current strength (which decays over time) and the reasoning that
28+
* motivated the change.
29+
*/
30+
export interface PersonalityMutation {
31+
/** Unique mutation identifier (format: `pm_<timestamp>_<random>`). */
32+
id: string;
33+
34+
/** The agent that made this mutation. */
35+
agentId: string;
36+
37+
/** The HEXACO trait that was mutated (e.g., `'openness'`, `'conscientiousness'`). */
38+
trait: string;
39+
40+
/** The signed delta applied to the trait value. Positive = increase, negative = decrease. */
41+
delta: number;
42+
43+
/** Free-text reasoning explaining why the agent chose to mutate this trait. */
44+
reasoning: string;
45+
46+
/** The trait value before this mutation was applied. */
47+
baselineValue: number;
48+
49+
/** The trait value after this mutation was applied. */
50+
mutatedValue: number;
51+
52+
/**
53+
* Current strength of this mutation in the range (0, 1].
54+
*
55+
* Starts at 1.0 when recorded and decays each consolidation cycle.
56+
* When strength drops to 0.1 or below, the mutation is pruned.
57+
*/
58+
strength: number;
59+
60+
/** Unix epoch millisecond timestamp of when this mutation was recorded. */
61+
createdAt: number;
62+
}
63+
64+
/**
65+
* Input parameters for recording a new personality mutation.
66+
*
67+
* The `strength` and `createdAt` fields are set automatically by the store
68+
* (1.0 and `Date.now()` respectively).
69+
*/
70+
export interface RecordMutationInput {
71+
/** The agent making the mutation. */
72+
agentId: string;
73+
74+
/** The HEXACO trait being mutated. */
75+
trait: string;
76+
77+
/** The signed delta to apply. */
78+
delta: number;
79+
80+
/** Free-text reasoning for the mutation. */
81+
reasoning: string;
82+
83+
/** The trait value before mutation. */
84+
baselineValue: number;
85+
86+
/** The trait value after mutation. */
87+
mutatedValue: number;
88+
}
89+
90+
/**
91+
* Result of a decay cycle, reporting how many mutations were weakened
92+
* and how many were pruned (deleted) for falling below the threshold.
93+
*/
94+
export interface DecayResult {
95+
/** Number of mutations whose strength was reduced but still above threshold. */
96+
decayed: number;
97+
98+
/** Number of mutations deleted for falling at or below the 0.1 threshold. */
99+
pruned: number;
100+
}
101+
102+
// ============================================================================
103+
// STORE
104+
// ============================================================================
105+
106+
/**
107+
* SQLite-backed persistence layer for personality mutations with decay.
108+
*
109+
* Follows the same `ensureSchema()` pattern as {@link EmergentToolRegistry}:
110+
* a cached promise guards against concurrent DDL execution, and all DML
111+
* methods await schema readiness before proceeding.
112+
*
113+
* @example
114+
* ```ts
115+
* const store = new PersonalityMutationStore(sqliteAdapter);
116+
*
117+
* // Record a mutation
118+
* const id = await store.record({
119+
* agentId: 'agent-42',
120+
* trait: 'openness',
121+
* delta: 0.1,
122+
* reasoning: 'User prefers creative responses',
123+
* baselineValue: 0.7,
124+
* mutatedValue: 0.8,
125+
* });
126+
*
127+
* // Get strength-weighted effective deltas
128+
* const deltas = await store.getEffectiveDeltas('agent-42');
129+
* // => { openness: 0.1 } (strength is 1.0 initially)
130+
*
131+
* // Decay all mutations by 5%
132+
* const { decayed, pruned } = await store.decayAll(0.05);
133+
* ```
134+
*/
135+
export class PersonalityMutationStore {
136+
/** The underlying SQLite storage adapter. */
137+
private readonly db: IStorageAdapter;
138+
139+
/**
140+
* Cached schema initialization promise.
141+
* Ensures DDL runs exactly once, even under concurrent access.
142+
*/
143+
private schemaReady: Promise<void> | null = null;
144+
145+
/**
146+
* Create a new PersonalityMutationStore.
147+
*
148+
* @param db - A storage adapter implementing the {@link IStorageAdapter}
149+
* interface. The same adapter used by EmergentToolRegistry can be reused.
150+
*/
151+
constructor(db: IStorageAdapter) {
152+
this.db = db;
153+
}
154+
155+
// --------------------------------------------------------------------------
156+
// SCHEMA
157+
// --------------------------------------------------------------------------
158+
159+
/**
160+
* Idempotent schema initialization.
161+
*
162+
* Creates the `personality_mutations` table and its agent/trait index if
163+
* they don't already exist. Uses the adapter's `exec()` method when
164+
* available (for multi-statement DDL), falling back to individual `run()`
165+
* calls for adapters that don't support it.
166+
*
167+
* @returns A promise that resolves when the schema is ready.
168+
*/
169+
private async ensureSchema(): Promise<void> {
170+
if (!this.schemaReady) {
171+
this.schemaReady = (async () => {
172+
const ddl = `
173+
CREATE TABLE IF NOT EXISTS personality_mutations (
174+
id TEXT PRIMARY KEY,
175+
agent_id TEXT NOT NULL,
176+
trait TEXT NOT NULL,
177+
delta REAL NOT NULL,
178+
reasoning TEXT NOT NULL,
179+
baseline_value REAL NOT NULL,
180+
mutated_value REAL NOT NULL,
181+
strength REAL NOT NULL DEFAULT 1.0,
182+
created_at BIGINT NOT NULL
183+
);
184+
CREATE INDEX IF NOT EXISTS idx_personality_mutations_agent
185+
ON personality_mutations(agent_id, trait);
186+
`;
187+
188+
if (this.db.exec) {
189+
await this.db.exec(ddl);
190+
} else {
191+
// Split on semicolons and execute each non-empty statement individually.
192+
for (const stmt of ddl.split(';').filter((s) => s.trim())) {
193+
await this.db.run(stmt);
194+
}
195+
}
196+
})();
197+
}
198+
return this.schemaReady;
199+
}
200+
201+
// --------------------------------------------------------------------------
202+
// RECORD
203+
// --------------------------------------------------------------------------
204+
205+
/**
206+
* Record a new personality mutation.
207+
*
208+
* Inserts a mutation record with initial strength of 1.0 and the current
209+
* timestamp. The mutation ID is generated deterministically from the
210+
* current time and a random suffix.
211+
*
212+
* @param input - The mutation parameters (agent, trait, delta, reasoning, values).
213+
* @returns The generated mutation ID.
214+
*/
215+
async record(input: RecordMutationInput): Promise<string> {
216+
await this.ensureSchema();
217+
218+
const id = `pm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
219+
220+
await this.db.run(
221+
`INSERT INTO personality_mutations
222+
(id, agent_id, trait, delta, reasoning, baseline_value, mutated_value, strength, created_at)
223+
VALUES (?, ?, ?, ?, ?, ?, ?, 1.0, ?)`,
224+
[
225+
id,
226+
input.agentId,
227+
input.trait,
228+
input.delta,
229+
input.reasoning,
230+
input.baselineValue,
231+
input.mutatedValue,
232+
Date.now(),
233+
],
234+
);
235+
236+
return id;
237+
}
238+
239+
// --------------------------------------------------------------------------
240+
// LOAD
241+
// --------------------------------------------------------------------------
242+
243+
/**
244+
* Load all active mutations for a given agent.
245+
*
246+
* Returns only mutations whose strength is above the 0.1 pruning threshold,
247+
* ordered by creation time (newest first).
248+
*
249+
* @param agentId - The agent whose mutations to load.
250+
* @returns An array of {@link PersonalityMutation} records.
251+
*/
252+
async loadForAgent(agentId: string): Promise<PersonalityMutation[]> {
253+
await this.ensureSchema();
254+
255+
const rows = await this.db.all(
256+
'SELECT * FROM personality_mutations WHERE agent_id = ? AND strength > 0.1 ORDER BY created_at DESC',
257+
[agentId],
258+
);
259+
260+
return (rows as Record<string, unknown>[]).map((r) => ({
261+
id: r.id as string,
262+
agentId: r.agent_id as string,
263+
trait: r.trait as string,
264+
delta: r.delta as number,
265+
reasoning: r.reasoning as string,
266+
baselineValue: r.baseline_value as number,
267+
mutatedValue: r.mutated_value as number,
268+
strength: r.strength as number,
269+
createdAt: r.created_at as number,
270+
}));
271+
}
272+
273+
// --------------------------------------------------------------------------
274+
// EFFECTIVE DELTAS
275+
// --------------------------------------------------------------------------
276+
277+
/**
278+
* Compute the effective (strength-weighted) delta for each trait.
279+
*
280+
* For each active mutation, multiplies the raw delta by the mutation's
281+
* current strength, then sums per trait. This gives a realistic picture
282+
* of how much each trait has drifted from baseline, accounting for decay.
283+
*
284+
* @param agentId - The agent whose effective deltas to compute.
285+
* @returns A map of trait name to effective delta (sum of `delta * strength`).
286+
*/
287+
async getEffectiveDeltas(agentId: string): Promise<Record<string, number>> {
288+
const mutations = await this.loadForAgent(agentId);
289+
const deltas: Record<string, number> = {};
290+
291+
for (const m of mutations) {
292+
deltas[m.trait] = (deltas[m.trait] ?? 0) + m.delta * m.strength;
293+
}
294+
295+
return deltas;
296+
}
297+
298+
// --------------------------------------------------------------------------
299+
// DECAY
300+
// --------------------------------------------------------------------------
301+
302+
/**
303+
* Decay all active mutations by the given rate and prune expired ones.
304+
*
305+
* For each mutation with strength above 0.1:
306+
* - Subtracts `rate` from its strength.
307+
* - If the new strength is at or below 0.1, the mutation is deleted (pruned).
308+
* - Otherwise, the strength is updated in place.
309+
*
310+
* This implements Ebbinghaus-style forgetting: mutations that aren't
311+
* reinforced by repeated adaptation gradually fade away.
312+
*
313+
* @param rate - The amount to subtract from each mutation's strength.
314+
* Typically 0.05 (the default from SelfImprovementConfig).
315+
* @returns A {@link DecayResult} with counts of decayed and pruned mutations.
316+
*/
317+
async decayAll(rate: number): Promise<DecayResult> {
318+
await this.ensureSchema();
319+
320+
const all = await this.db.all(
321+
'SELECT id, strength FROM personality_mutations WHERE strength > 0.1',
322+
[],
323+
);
324+
325+
let decayed = 0;
326+
let pruned = 0;
327+
328+
for (const row of all as Array<{ id: string; strength: number }>) {
329+
const newStrength = row.strength - rate;
330+
331+
if (newStrength <= 0.1) {
332+
await this.db.run('DELETE FROM personality_mutations WHERE id = ?', [row.id]);
333+
pruned++;
334+
} else {
335+
await this.db.run(
336+
'UPDATE personality_mutations SET strength = ? WHERE id = ?',
337+
[newStrength, row.id],
338+
);
339+
decayed++;
340+
}
341+
}
342+
343+
return { decayed, pruned };
344+
}
345+
}

0 commit comments

Comments
 (0)