Skip to content

Commit 59bb14e

Browse files
author
StackMemory Bot (CLI)
committed
feat(core): add retention decay scoring, entity state tracking, and chunk enrichment
- Replace static importance scoring with Ebbinghaus decay formula: R = salience × e^(-λ × Δt) + σ × Σ(1/(t - t_access)) - Add frame_access_log table and recordFrameAccess() for reinforcement - Add entity_states table with git-style append-only temporal model (recordEntityState, getEntityState, getEntityHistory, getEntityDiff) - Add LLM-based chunk enrichment (frame-enrichment.ts) for pronoun resolution and entity extraction at ingest time - Update gc.test.ts to use relative assertions matching decay formula - 14 new tests for retention decay, access logging, entity state
1 parent 4462515 commit 59bb14e

7 files changed

Lines changed: 762 additions & 47 deletions

File tree

src/core/config/config-manager.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
DEFAULT_CONFIG,
1313
PRESET_PROFILES,
1414
ScoringWeights,
15+
DEFAULT_ENRICHMENT,
1516
} from './types.js';
1617

1718
export interface ValidationResult {
@@ -95,6 +96,7 @@ export class ConfigManager {
9596
},
9697
},
9798
performance: { ...DEFAULT_CONFIG.performance, ...loaded.performance },
99+
enrichment: { ...DEFAULT_ENRICHMENT, ...loaded.enrichment },
98100
profiles: { ...PRESET_PROFILES, ...loaded.profiles },
99101
};
100102

src/core/config/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export interface RetentionConfig {
4242
};
4343
}
4444

45+
export interface EnrichmentConfig {
46+
enabled: boolean;
47+
lookbackDepth: number;
48+
extractEntities: boolean;
49+
}
50+
4551
export interface PerformanceConfig {
4652
max_stack_depth: number;
4753
max_frame_events: number;
@@ -69,6 +75,7 @@ export interface StackMemoryConfig {
6975
};
7076
retention: RetentionConfig;
7177
performance: PerformanceConfig;
78+
enrichment: EnrichmentConfig;
7279
profiles?: Record<string, ProfileConfig>;
7380
}
7481

@@ -166,6 +173,12 @@ export const PRESET_PROFILES: Record<string, ProfileConfig> = {
166173
},
167174
};
168175

176+
export const DEFAULT_ENRICHMENT: EnrichmentConfig = {
177+
enabled: false,
178+
lookbackDepth: 3,
179+
extractEntities: true,
180+
};
181+
169182
export const DEFAULT_CONFIG: StackMemoryConfig = {
170183
version: '1.0',
171184
scoring: {
@@ -196,5 +209,6 @@ export const DEFAULT_CONFIG: StackMemoryConfig = {
196209
retrieval_timeout_ms: 500,
197210
batch_upload_size: 100,
198211
},
212+
enrichment: DEFAULT_ENRICHMENT,
199213
profiles: PRESET_PROFILES,
200214
};

src/core/context/frame-database.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,54 @@ export class FrameDatabase {
227227
INSERT OR IGNORE INTO schema_version (version) VALUES (1);
228228
`);
229229

230+
// Migration: add access_count column if missing
231+
try {
232+
this.db.exec(
233+
'ALTER TABLE frames ADD COLUMN access_count INTEGER DEFAULT 0'
234+
);
235+
} catch {
236+
// Column already exists
237+
}
238+
239+
// Migration: add last_accessed column if missing
240+
try {
241+
this.db.exec('ALTER TABLE frames ADD COLUMN last_accessed INTEGER');
242+
} catch {
243+
// Column already exists
244+
}
245+
246+
// Frame access log for retention decay scoring
247+
this.db.exec(`
248+
CREATE TABLE IF NOT EXISTS frame_access_log (
249+
id INTEGER PRIMARY KEY AUTOINCREMENT,
250+
frame_id TEXT NOT NULL,
251+
accessed_at INTEGER NOT NULL DEFAULT (unixepoch()),
252+
FOREIGN KEY (frame_id) REFERENCES frames(frame_id) ON DELETE CASCADE
253+
);
254+
CREATE INDEX IF NOT EXISTS idx_frame_access_log_frame
255+
ON frame_access_log(frame_id);
256+
`);
257+
258+
// Temporal entity state table (append-only knowledge graph)
259+
this.db.exec(`
260+
CREATE TABLE IF NOT EXISTS entity_states (
261+
id INTEGER PRIMARY KEY AUTOINCREMENT,
262+
project_id TEXT NOT NULL,
263+
entity_name TEXT NOT NULL,
264+
relation TEXT NOT NULL,
265+
value TEXT NOT NULL,
266+
context TEXT,
267+
source_frame_id TEXT,
268+
valid_from INTEGER NOT NULL DEFAULT (unixepoch()),
269+
superseded_at INTEGER,
270+
FOREIGN KEY (source_frame_id) REFERENCES frames(frame_id)
271+
);
272+
CREATE INDEX IF NOT EXISTS idx_entity_name
273+
ON entity_states(project_id, entity_name, relation);
274+
CREATE INDEX IF NOT EXISTS idx_entity_temporal
275+
ON entity_states(entity_name, valid_from DESC);
276+
`);
277+
230278
// Cord task orchestration table (dependency DAG for multi-agent coordination)
231279
this.db.exec(`
232280
CREATE TABLE IF NOT EXISTS cord_tasks (
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Frame Enrichment - Optional LLM-based chunk enrichment at frame close.
3+
* Resolves pronouns/entities and extracts entity states from frame content.
4+
* Registers as a frame lifecycle close hook.
5+
*/
6+
7+
import { logger } from '../monitoring/logger.js';
8+
import { frameLifecycleHooks } from './frame-lifecycle-hooks.js';
9+
import type { Frame } from './frame-types.js';
10+
import type { EnrichmentConfig } from '../config/types.js';
11+
12+
export interface EnrichmentResult {
13+
enrichedDigest: string;
14+
entities: ExtractedEntity[];
15+
}
16+
17+
export interface ExtractedEntity {
18+
name: string;
19+
relation: string;
20+
value: string;
21+
context?: string;
22+
}
23+
24+
export interface FrameEnrichmentDeps {
25+
getParentFrames: (frameId: string, depth: number) => Promise<Frame[]>;
26+
updateDigest: (frameId: string, digest: string) => Promise<void>;
27+
recordEntity: (
28+
projectId: string,
29+
name: string,
30+
relation: string,
31+
value: string,
32+
context?: string,
33+
sourceFrameId?: string
34+
) => void;
35+
}
36+
37+
const ENRICHMENT_PROMPT = `You are a context enrichment engine. Given a frame digest and its parent context, do two things:
38+
1. Rewrite the digest to be fully self-contained — resolve all pronouns ("it", "that", "the project") using parent context.
39+
2. Extract entity-relation-value triples from the content.
40+
41+
Respond in JSON only:
42+
{"enrichedDigest":"...","entities":[{"name":"...","relation":"...","value":"...","context":"..."}]}`;
43+
44+
function buildEnrichmentInput(frame: Frame, parents: Frame[]): string {
45+
const parentCtx = parents
46+
.map((p) => `[${p.name}]: ${p.digest_text ?? ''}`)
47+
.join('\n');
48+
return `Parent context:\n${parentCtx}\n\nCurrent frame "${frame.name}":\n${frame.digest_text ?? ''}`;
49+
}
50+
51+
export async function enrichFrame(
52+
frame: Frame,
53+
parents: Frame[],
54+
apiKey: string
55+
): Promise<EnrichmentResult | undefined> {
56+
try {
57+
const Anthropic = (await import('@anthropic-ai/sdk')).default;
58+
const client = new Anthropic({ apiKey });
59+
const input = buildEnrichmentInput(frame, parents);
60+
61+
const response = await client.messages.create({
62+
model: 'claude-3-5-haiku-latest',
63+
max_tokens: 1024,
64+
system: ENRICHMENT_PROMPT,
65+
messages: [{ role: 'user', content: input }],
66+
});
67+
68+
const text =
69+
response.content[0].type === 'text' ? response.content[0].text : '';
70+
return JSON.parse(text) as EnrichmentResult;
71+
} catch (err) {
72+
logger.warn('Frame enrichment LLM call failed', {
73+
error: err instanceof Error ? err.message : String(err),
74+
frameId: frame.frame_id,
75+
});
76+
return undefined;
77+
}
78+
}
79+
80+
let unregister: (() => void) | null = null;
81+
82+
export function registerEnrichmentHook(
83+
config: EnrichmentConfig,
84+
deps: FrameEnrichmentDeps
85+
): () => void {
86+
if (unregister) unregister();
87+
if (!config.enabled) return () => {};
88+
89+
const apiKey = process.env.ANTHROPIC_API_KEY;
90+
if (!apiKey) {
91+
logger.warn('Enrichment enabled but ANTHROPIC_API_KEY not set');
92+
return () => {};
93+
}
94+
95+
unregister = frameLifecycleHooks.onFrameClosed(
96+
'frame-enrichment',
97+
async (data) => {
98+
if (!data.frame.digest_text) return;
99+
100+
const parents = await deps.getParentFrames(
101+
data.frame.frame_id,
102+
config.lookbackDepth
103+
);
104+
105+
const result = await enrichFrame(data.frame, parents, apiKey);
106+
if (!result) return;
107+
108+
await deps.updateDigest(data.frame.frame_id, result.enrichedDigest);
109+
110+
if (config.extractEntities) {
111+
for (const entity of result.entities) {
112+
try {
113+
deps.recordEntity(
114+
data.frame.project_id,
115+
entity.name,
116+
entity.relation,
117+
entity.value,
118+
entity.context,
119+
data.frame.frame_id
120+
);
121+
} catch {
122+
// best-effort per entity
123+
}
124+
}
125+
}
126+
},
127+
-10 // low priority — runs after other hooks
128+
);
129+
130+
return () => {
131+
if (unregister) {
132+
unregister();
133+
unregister = null;
134+
}
135+
};
136+
}

src/core/database/__tests__/gc.test.ts

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -413,8 +413,9 @@ describe('Garbage Collection', () => {
413413
});
414414

415415
const score = adapter.computeImportanceScore('empty-frame');
416-
// Base score: 0.3 (no anchors, no events > 3, no digest, no children, not recent)
417-
expect(score).toBe(0.3);
416+
// Ebbinghaus decay: salience(0.3) * exp(-0.05 * 10) ≈ 0.18
417+
expect(score).toBeGreaterThanOrEqual(0.05);
418+
expect(score).toBeLessThan(0.3);
418419
});
419420

420421
it('should increase score for frames with DECISION anchors', async () => {
@@ -424,9 +425,15 @@ describe('Garbage Collection', () => {
424425
});
425426
insertAnchor('decision-frame', 'anc-decision', 'DECISION');
426427

428+
const baseScore = adapter.computeImportanceScore('empty-frame-baseline');
429+
insertFrame({ frameId: 'empty-frame-baseline', createdAt: daysAgo(10) });
430+
const baselineScore = adapter.computeImportanceScore(
431+
'empty-frame-baseline'
432+
);
433+
427434
const score = adapter.computeImportanceScore('decision-frame');
428-
// Base 0.3 + 0.15 (DECISION) = 0.45
429-
expect(score).toBe(0.45);
435+
// DECISION anchor increases salience, so score should be higher than baseline
436+
expect(score).toBeGreaterThan(baselineScore);
430437
});
431438

432439
it('should increase score for frames with digest_text', async () => {
@@ -435,10 +442,12 @@ describe('Garbage Collection', () => {
435442
createdAt: daysAgo(10),
436443
digestText: 'This frame has a digest',
437444
});
445+
insertFrame({ frameId: 'no-digest-frame', createdAt: daysAgo(10) });
438446

439-
const score = adapter.computeImportanceScore('digest-frame');
440-
// Base 0.3 + 0.15 (digest) = 0.45
441-
expect(score).toBe(0.45);
447+
const digestScore = adapter.computeImportanceScore('digest-frame');
448+
const baseScore = adapter.computeImportanceScore('no-digest-frame');
449+
// digest_text increases salience
450+
expect(digestScore).toBeGreaterThan(baseScore);
442451
});
443452

444453
it('should increase score for frames with many events', async () => {
@@ -449,10 +458,12 @@ describe('Garbage Collection', () => {
449458
for (let i = 0; i < 5; i++) {
450459
insertEvent('eventful-frame', `evt-${i}`);
451460
}
461+
insertFrame({ frameId: 'quiet-frame', createdAt: daysAgo(10) });
452462

453-
const score = adapter.computeImportanceScore('eventful-frame');
454-
// Base 0.3 + 0.1 (events > 3) = 0.4
455-
expect(score).toBe(0.4);
463+
const eventScore = adapter.computeImportanceScore('eventful-frame');
464+
const baseScore = adapter.computeImportanceScore('quiet-frame');
465+
// >3 events increases salience
466+
expect(eventScore).toBeGreaterThan(baseScore);
456467
});
457468

458469
it('should increase score for recent frames', async () => {
@@ -461,10 +472,15 @@ describe('Garbage Collection', () => {
461472
frameId: 'recent-frame',
462473
createdAt: nowSec - 3600, // 1 hour ago
463474
});
475+
insertFrame({
476+
frameId: 'old-frame-compare',
477+
createdAt: daysAgo(30),
478+
});
464479

465-
const score = adapter.computeImportanceScore('recent-frame');
466-
// Base 0.3 + 0.1 (recency) = 0.4
467-
expect(score).toBe(0.4);
480+
const recentScore = adapter.computeImportanceScore('recent-frame');
481+
const oldScore = adapter.computeImportanceScore('old-frame-compare');
482+
// Recent frames decay less, so score should be higher
483+
expect(recentScore).toBeGreaterThan(oldScore);
468484
});
469485

470486
it('should cap score at 1.0', async () => {
@@ -486,8 +502,8 @@ describe('Garbage Collection', () => {
486502
).run(nowSec);
487503

488504
const score = adapter.computeImportanceScore('max-frame');
489-
// 0.3 + 0.15 (DECISION) + 0.1 (events) + 0.15 (digest) + 0.1 (children) + 0.1 (recency) = 0.9
490-
expect(score).toBe(0.9);
505+
// All factors + recent → high score, still capped at 1.0
506+
expect(score).toBeGreaterThan(0.5);
491507
expect(score).toBeLessThanOrEqual(1.0);
492508
});
493509

@@ -833,15 +849,15 @@ describe('Garbage Collection', () => {
833849

834850
const updated = adapter.recomputeImportanceScores(100);
835851

836-
// score-1 should get recomputed (0.3 + 0.15 digest = 0.45, != 0.5 so updated)
837-
// score-2 should get recomputed (0.3, != 0.5 so updated)
852+
// Both should get recomputed (decay formula produces values != 0.5)
838853
expect(updated).toBe(2);
839854

840855
const score1 = getImportanceScore('score-1');
841-
expect(score1).toBe(0.45);
842-
843856
const score2 = getImportanceScore('score-2');
844-
expect(score2).toBe(0.3);
857+
// score-1 has digest → higher salience → higher score than score-2
858+
expect(score1).toBeGreaterThan(score2);
859+
expect(score1).not.toBe(0.5);
860+
expect(score2).not.toBe(0.5);
845861
});
846862
});
847863
});

0 commit comments

Comments
 (0)