import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { MindDB } from '../../src/mind/db.js'; import { FrameStore, type MemoryFrame, type FrameType, type Importance } from '../../src/mind/frames.js'; import { SessionStore, type Session } from '../../src/mind/sessions.js'; import { HybridSearch } from '../../src/mind/search.js'; import { MockEmbedder } from './helpers/mock-embedder.js'; describe('Memory Frames (Layer 2 - The Codec)', () => { let db: MindDB; let frames: FrameStore; let sessions: SessionStore; beforeEach(() => { db = new MindDB(':memory:'); frames = new FrameStore(db); sessions = new SessionStore(db); }); afterEach(() => { db.close(); }); describe('Session management', () => { it('creates a session with generated gop_id', () => { const session = sessions.create(); expect(session.gop_id).toMatch(/^session:/); expect(session.status).toBe('active'); }); it('creates a session linked to a project', () => { const session = sessions.create('project:waggle'); expect(session.project_id).toBe('project:waggle'); }); it('closes a session', () => { const session = sessions.create(); const closed = sessions.close(session.gop_id, 'Session complete'); expect(closed.status).toBe('closed'); expect(closed.summary).toBe('Session complete'); expect(closed.ended_at).toBeDefined(); }); it('lists sessions by project', () => { sessions.create('project:a'); sessions.create('project:a'); sessions.create('project:b'); expect(sessions.getByProject('project:a')).toHaveLength(2); expect(sessions.getByProject('project:b')).toHaveLength(1); }); it('gets active sessions', () => { const s1 = sessions.create(); sessions.create(); sessions.close(s1.gop_id); expect(sessions.getActive()).toHaveLength(1); }); }); describe('I-Frame creation', () => { it('creates an I-Frame (full snapshot)', () => { const session = sessions.create(); const frame = frames.createIFrame(session.gop_id, 'Full state snapshot at start of session'); expect(frame.frame_type).toBe('I'); expect(frame.gop_id).toBe(session.gop_id); expect(frame.t).toBe(0); expect(frame.base_frame_id).toBeNull(); expect(frame.importance).toBe('normal'); }); it('I-Frame t=0 has no base_frame', () => { const session = sessions.create(); const frame = frames.createIFrame(session.gop_id, 'Keyframe'); expect(frame.base_frame_id).toBeNull(); }); // ── Sprint 9 Task 0 regression (port of hive-mind 9ec75e6) ─────────── // The harvest path depends on `createdAt` overriding the schema // default so frames preserve the original source timestamp instead of // the ingest wall-clock. Without these guards a future refactor could // silently re-introduce the Stage 0 ABSTAIN failure mode. it('createIFrame honors a valid ISO-8601 createdAt override', () => { const session = sessions.create(); const ts = '2025-12-01T14:32:00Z'; const f = frames.createIFrame(session.gop_id, 'harvested content', 'normal', 'import', ts); expect(f.created_at).toBe(ts); expect(f.last_accessed).toBe(ts); }); it('createIFrame with undefined createdAt falls back to schema default (NOW())', () => { const session = sessions.create(); const before = Date.now(); const f = frames.createIFrame(session.gop_id, 'undefined-ts content', 'normal', 'import', undefined); const after = Date.now(); // SQLite datetime('now') returns UTC "YYYY-MM-DD HH:MM:SS" (no T, no Z). expect(f.created_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); const parsed = Date.parse(f.created_at.replace(' ', 'T') + 'Z'); expect(parsed).toBeGreaterThanOrEqual(before - 5000); expect(parsed).toBeLessThanOrEqual(after + 5000); }); it('createIFrame with invalid-ISO string falls back to schema default (no junk in DB)', () => { const session = sessions.create(); const f = frames.createIFrame( session.gop_id, 'invalid-ts content', 'normal', 'import', 'not-a-valid-iso-string', ); // The literal junk must never reach storage — otherwise range queries // on created_at silently break. expect(f.created_at).not.toBe('not-a-valid-iso-string'); expect(f.created_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); }); it('createIFrame with null createdAt falls back to schema default', () => { // null is the explicit "no timestamp" signal the harvest route // passes after its own validator rejects malformed input. const session = sessions.create(); const f = frames.createIFrame(session.gop_id, 'null-ts content', 'normal', 'import', null); expect(f.created_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); }); }); describe('P-Frame creation', () => { it('creates a P-Frame (delta) referencing an I-Frame', () => { const session = sessions.create(); const iframe = frames.createIFrame(session.gop_id, 'Full snapshot'); const pframe = frames.createPFrame(session.gop_id, 'User asked about weather', iframe.id); expect(pframe.frame_type).toBe('P'); expect(pframe.base_frame_id).toBe(iframe.id); expect(pframe.t).toBe(1); }); it('P-Frames auto-increment t within GOP', () => { const session = sessions.create(); const iframe = frames.createIFrame(session.gop_id, 'Keyframe'); const p1 = frames.createPFrame(session.gop_id, 'Delta 1', iframe.id); const p2 = frames.createPFrame(session.gop_id, 'Delta 2', iframe.id); const p3 = frames.createPFrame(session.gop_id, 'Delta 3', iframe.id); expect(p1.t).toBe(1); expect(p2.t).toBe(2); expect(p3.t).toBe(3); }); }); describe('B-Frame creation', () => { it('creates a B-Frame (cross-reference) linking frames across GOPs', () => { const s1 = sessions.create(); const s2 = sessions.create(); const iframe1 = frames.createIFrame(s1.gop_id, 'Session 1 snapshot'); const iframe2 = frames.createIFrame(s2.gop_id, 'Session 2 snapshot'); const bframe = frames.createBFrame( s1.gop_id, 'References related discussion in session 2', iframe1.id, [iframe2.id] ); expect(bframe.frame_type).toBe('B'); expect(bframe.base_frame_id).toBe(iframe1.id); }); }); describe('Frame retrieval', () => { it('gets latest I-Frame within a GOP', () => { const session = sessions.create(); frames.createIFrame(session.gop_id, 'First keyframe'); frames.createPFrame(session.gop_id, 'Delta', 1); frames.createIFrame(session.gop_id, 'Second keyframe'); const latest = frames.getLatestIFrame(session.gop_id); expect(latest).toBeDefined(); expect(latest!.content).toBe('Second keyframe'); }); it('gets P-Frames since last I-Frame', () => { const session = sessions.create(); const i1 = frames.createIFrame(session.gop_id, 'KF1'); frames.createPFrame(session.gop_id, 'Old delta', i1.id); const i2 = frames.createIFrame(session.gop_id, 'KF2'); frames.createPFrame(session.gop_id, 'New delta 1', i2.id); frames.createPFrame(session.gop_id, 'New delta 2', i2.id); const pframes = frames.getPFramesSinceLastI(session.gop_id); expect(pframes).toHaveLength(2); expect(pframes[0].content).toBe('New delta 1'); }); it('gets all frames for a GOP (window query)', () => { const session = sessions.create(); frames.createIFrame(session.gop_id, 'KF'); frames.createPFrame(session.gop_id, 'D1', 1); frames.createPFrame(session.gop_id, 'D2', 1); const all = frames.getGopFrames(session.gop_id); expect(all).toHaveLength(3); }); }); describe('State reconstruction', () => { it('reconstructs state from latest I + all P-deltas in GOP', () => { const session = sessions.create(); frames.createIFrame(session.gop_id, JSON.stringify({ tasks: ['buy milk'], notes: 'Morning briefing' })); frames.createPFrame(session.gop_id, JSON.stringify({ tasks_add: ['review PR'], action: 'checked email' }), 1); frames.createPFrame(session.gop_id, JSON.stringify({ tasks_add: ['deploy v2'], notes_append: ' Updated plan.' }), 1); const state = frames.reconstructState(session.gop_id); expect(state.iframe).toBeDefined(); expect(state.pframes).toHaveLength(2); expect(state.iframe!.content).toContain('buy milk'); }); it('returns null iframe when GOP has no frames', () => { const session = sessions.create(); const state = frames.reconstructState(session.gop_id); expect(state.iframe).toBeNull(); expect(state.pframes).toHaveLength(0); }); }); describe('P-Frame compression', () => { it('P-Frames are significantly smaller than I-Frames', () => { const session = sessions.create(); const bigContent = JSON.stringify({ tasks: Array.from({ length: 20 }, (_, i) => `Task ${i}: ${Array(50).fill('x').join('')}`), notes: Array(200).fill('Full context note.').join(' '), context: { user: 'Marko', project: 'Waggle', phase: 'POC' }, }); const iframe = frames.createIFrame(session.gop_id, bigContent); const pframe = frames.createPFrame(session.gop_id, JSON.stringify({ tasks_add: ['small update'] }), iframe.id); expect(pframe.content.length).toBeLessThan(iframe.content.length * 0.5); }); }); describe('Access tracking', () => { it('touch increments access count', () => { const session = sessions.create(); const frame = frames.createIFrame(session.gop_id, 'Test'); expect(frame.access_count).toBe(0); frames.touch(frame.id); frames.touch(frame.id); frames.touch(frame.id); const updated = frames.getById(frame.id); expect(updated!.access_count).toBe(3); }); it('touch updates last_accessed timestamp', () => { const session = sessions.create(); const frame = frames.createIFrame(session.gop_id, 'Test'); const before = frame.last_accessed; frames.touch(frame.id); const after = frames.getById(frame.id)!.last_accessed; expect(after).toBeDefined(); }); }); describe('Importance levels', () => { it('supports all importance levels', () => { const session = sessions.create(); const levels: Importance[] = ['critical', 'important', 'normal', 'temporary', 'deprecated']; for (const level of levels) { const frame = frames.createIFrame(session.gop_id, `Frame: ${level}`, level); expect(frame.importance).toBe(level); } }); it('importance multipliers map correctly', () => { expect(frames.getImportanceMultiplier('critical')).toBe(2.0); expect(frames.getImportanceMultiplier('important')).toBe(1.5); expect(frames.getImportanceMultiplier('normal')).toBe(1.0); expect(frames.getImportanceMultiplier('temporary')).toBe(0.7); expect(frames.getImportanceMultiplier('deprecated')).toBe(0.3); }); }); describe('Cross-GOP B-frame references', () => { it('B-frame stores cross-references in content', () => { const s1 = sessions.create(); const s2 = sessions.create(); const i1 = frames.createIFrame(s1.gop_id, 'S1 KF'); const i2 = frames.createIFrame(s2.gop_id, 'S2 KF'); const bframe = frames.createBFrame(s1.gop_id, 'Link to S2 discussion', i1.id, [i2.id]); expect(bframe.frame_type).toBe('B'); const refs = frames.getBFrameReferences(bframe.id); expect(refs).toContain(i2.id); }); }); describe('getRecent', () => { it('returns recent frames sorted by created_at descending', () => { const session = sessions.create(); frames.createIFrame(session.gop_id, 'First'); frames.createIFrame(session.gop_id, 'Second'); frames.createIFrame(session.gop_id, 'Third'); const recent = frames.getRecent(2); expect(recent).toHaveLength(2); expect(recent[0].content).toBe('Third'); expect(recent[1].content).toBe('Second'); }); it('returns all frames when limit exceeds count', () => { const session = sessions.create(); frames.createIFrame(session.gop_id, 'Only'); const recent = frames.getRecent(100); expect(recent).toHaveLength(1); }); it('returns empty array for empty database', () => { expect(frames.getRecent(10)).toHaveLength(0); }); }); describe('Performance', () => { it('inserts 10,000 frames in under 10s', () => { const session = sessions.create(); const iframe = frames.createIFrame(session.gop_id, 'Initial keyframe'); const start = performance.now(); const raw = db.getDatabase(); const insertStmt = raw.prepare(` INSERT INTO memory_frames (frame_type, gop_id, t, base_frame_id, content, importance) VALUES ('P', ?, ?, ?, ?, 'normal') `); const insertMany = raw.transaction(() => { for (let i = 1; i <= 9999; i++) { insertStmt.run(session.gop_id, i, iframe.id, `Delta content ${i}: user interaction data`); } }); insertMany(); const elapsed = performance.now() - start; expect(elapsed).toBeLessThan(10_000); const count = raw.prepare('SELECT COUNT(*) as c FROM memory_frames').get() as { c: number }; expect(count.c).toBe(10_000); }); it('reconstructs state from 10,000 frames in under 100ms', () => { // Uses the frames inserted by prior test? No - each test has fresh DB. // Create a realistic scenario: 1 I-frame + 100 P-frames (typical GOP) const session = sessions.create(); const iframe = frames.createIFrame(session.gop_id, 'Keyframe with full state'); const raw = db.getDatabase(); const insertStmt = raw.prepare(` INSERT INTO memory_frames (frame_type, gop_id, t, base_frame_id, content, importance) VALUES ('P', ?, ?, ?, ?, 'normal') `); const insertMany = raw.transaction(() => { for (let i = 1; i <= 100; i++) { insertStmt.run(session.gop_id, i, iframe.id, `Delta ${i}`); } }); insertMany(); // Warm up for (let i = 0; i < 3; i++) frames.reconstructState(session.gop_id); const start = performance.now(); const iterations = 10; for (let i = 0; i < iterations; i++) { frames.reconstructState(session.gop_id); } const avgMs = (performance.now() - start) / iterations; expect(avgMs).toBeLessThan(100); }); }); it('compact() P-frame merge routes removal through delete(), leaving no orphan chunk-vec rows', async () => { // Regression: the merge branch used inline DELETEs that purged FTS + the // whole-frame vec but NOT memory_frame_chunks_vec (no FK cascade), orphaning // chunk-embedding rows. Routing through delete(id) purges them. // (Ported from hive-mind 2d0abc5, adapted to MockEmbedder + a real session // gop since the monorepo enforces the memory_frames.gop_id → sessions FK.) const embedder = new MockEmbedder(); const search = new HybridSearch(db, embedder); const session = sessions.create(); const base = frames.createIFrame(session.gop_id, 'base state for chunk-orphan compaction', 'important'); // >10 P-frames on one GOP triggers the merge branch (keeps 5, merges the rest). const pframes: MemoryFrame[] = []; for (let i = 0; i < 11; i++) { pframes.push( frames.createPFrame(session.gop_id, `partial update number ${i} with enough words to chunk cleanly`, base.id), ); } // Chunk-index the FIRST P-frame — it falls in the merge set (slice(0, 6)). const victim = pframes[0]; await search.indexChunksForFrame(victim.id, victim.content); const chunkIds = (db .getDatabase() .prepare('SELECT id FROM memory_frame_chunks WHERE frame_id = ?') .all(victim.id) as Array<{ id: number }>).map((r) => r.id); expect(chunkIds.length).toBeGreaterThan(0); const placeholders = chunkIds.map(() => '?').join(','); const vecBefore = db .getDatabase() .prepare(`SELECT COUNT(*) AS n FROM memory_frame_chunks_vec WHERE rowid IN (${placeholders})`) .get(...chunkIds) as { n: number }; expect(vecBefore.n).toBe(chunkIds.length); const result = frames.compact(30, 90); expect(result.pframesMerged).toBe(6); expect(frames.getById(victim.id)).toBeUndefined(); // Chunk table rows gone (FK cascade) AND their vec rows gone (delete() sweep). const chunkRowsAfter = db .getDatabase() .prepare('SELECT COUNT(*) AS n FROM memory_frame_chunks WHERE frame_id = ?') .get(victim.id) as { n: number }; expect(chunkRowsAfter.n).toBe(0); const vecAfter = db .getDatabase() .prepare(`SELECT COUNT(*) AS n FROM memory_frame_chunks_vec WHERE rowid IN (${placeholders})`) .get(...chunkIds) as { n: number }; expect(vecAfter.n).toBe(0); }); });