372 lines
15 KiB
TypeScript
372 lines
15 KiB
TypeScript
/**
|
|
* FrameStore tests — full-file port from
|
|
* hive-mind/packages/core/src/mind/frames.test.ts.
|
|
*
|
|
* Memory Sync Repair Step 2. Source: hive-mind file at HEAD c363257.
|
|
*
|
|
* Filename suffix `-hive-mind` keeps this distinct from waggle-os's own
|
|
* `frames.test.ts` (which exercises sessions+frames together with much
|
|
* broader coverage including importance multipliers, performance under
|
|
* 10K frames, getRecent/getGopFrames, etc.). Hive-mind's file focuses on
|
|
* the smaller surface: I/P/B frame creation, reconstructState, dedup,
|
|
* update, delete, compact, getStats — all of which exist in waggle-os.
|
|
*
|
|
* Also includes the 4 createIFrame createdAt cases ported in Step 1 —
|
|
* here as a duplicate-but-isolated check that the public API contract
|
|
* holds when exercised through the hive-mind test setup convention
|
|
* (raw INSERT into sessions vs SessionStore).
|
|
*
|
|
* Adapted imports: `./db.js`, `./frames.js` → `../../src/mind/...`.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { rmSync, existsSync } from 'node:fs';
|
|
import { MindDB } from '../../src/mind/db.js';
|
|
import { FrameStore, stripHmPrefix } from '../../src/mind/frames.js';
|
|
|
|
describe('FrameStore (hive-mind port)', () => {
|
|
let dbPath: string;
|
|
let db: MindDB;
|
|
let frames: FrameStore;
|
|
|
|
beforeEach(() => {
|
|
dbPath = join(tmpdir(), `waggle-mind-frames-test-${Date.now()}-${Math.random()}.mind`);
|
|
db = new MindDB(dbPath);
|
|
db.getDatabase()
|
|
.prepare("INSERT INTO sessions (gop_id, status, started_at) VALUES ('gop-test', 'active', datetime('now'))")
|
|
.run();
|
|
frames = new FrameStore(db);
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
if (existsSync(dbPath)) rmSync(dbPath);
|
|
for (const suffix of ['-shm', '-wal']) {
|
|
if (existsSync(dbPath + suffix)) rmSync(dbPath + suffix);
|
|
}
|
|
});
|
|
|
|
it('creates I-frames with monotonically increasing t within a GOP', () => {
|
|
const a = frames.createIFrame('gop-test', 'first', 'normal');
|
|
const b = frames.createIFrame('gop-test', 'second', 'normal');
|
|
expect(a.frame_type).toBe('I');
|
|
expect(b.frame_type).toBe('I');
|
|
expect(a.t).toBe(0);
|
|
expect(b.t).toBe(1);
|
|
expect(b.id).toBeGreaterThan(a.id);
|
|
});
|
|
|
|
it('createPFrame attaches to a base I-frame', () => {
|
|
const iframe = frames.createIFrame('gop-test', 'base state');
|
|
const pframe = frames.createPFrame('gop-test', 'delta update', iframe.id);
|
|
expect(pframe.frame_type).toBe('P');
|
|
expect(pframe.base_frame_id).toBe(iframe.id);
|
|
});
|
|
|
|
it('createBFrame stores referenced frame IDs in the parsed content', () => {
|
|
const a = frames.createIFrame('gop-test', 'A');
|
|
const b = frames.createIFrame('gop-test', 'B');
|
|
const c = frames.createIFrame('gop-test', 'C');
|
|
const bridge = frames.createBFrame('gop-test', 'links a-b-c', a.id, [b.id, c.id]);
|
|
expect(bridge.frame_type).toBe('B');
|
|
expect(frames.getBFrameReferences(bridge.id)).toEqual([b.id, c.id]);
|
|
});
|
|
|
|
it('reconstructState returns the latest I-frame and following P-frames', () => {
|
|
const iframe = frames.createIFrame('gop-test', 'state v1', 'important');
|
|
frames.createPFrame('gop-test', 'delta 1', iframe.id);
|
|
frames.createPFrame('gop-test', 'delta 2', iframe.id);
|
|
|
|
const state = frames.reconstructState('gop-test');
|
|
expect(state.iframe?.id).toBe(iframe.id);
|
|
expect(state.pframes).toHaveLength(2);
|
|
expect(state.pframes.map((p) => p.content)).toEqual(['delta 1', 'delta 2']);
|
|
});
|
|
|
|
it('createIFrame honors a valid ISO-8601 createdAt override', () => {
|
|
const ts = '2025-12-01T14:32:00Z';
|
|
const f = frames.createIFrame('gop-test', '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 the schema default (NOW())', () => {
|
|
const before = Date.now();
|
|
const f = frames.createIFrame('gop-test', 'undefined-ts content', 'normal', 'import', undefined);
|
|
const after = Date.now();
|
|
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 an invalid-ISO string falls back to the schema default (NOW())', () => {
|
|
const before = Date.now();
|
|
const f = frames.createIFrame(
|
|
'gop-test',
|
|
'invalid-ts content',
|
|
'normal',
|
|
'import',
|
|
'not-a-valid-iso-string',
|
|
);
|
|
const after = Date.now();
|
|
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}$/);
|
|
const parsed = Date.parse(f.created_at.replace(' ', 'T') + 'Z');
|
|
expect(parsed).toBeGreaterThanOrEqual(before - 5000);
|
|
expect(parsed).toBeLessThanOrEqual(after + 5000);
|
|
});
|
|
|
|
it('createIFrame with a null createdAt falls back to the schema default', () => {
|
|
const f = frames.createIFrame('gop-test', 'null-ts content', 'normal', 'import', null);
|
|
expect(f.created_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
|
});
|
|
|
|
it('dedups identical content on createIFrame and increments access_count', () => {
|
|
const first = frames.createIFrame('gop-test', 'repeated content');
|
|
const second = frames.createIFrame('gop-test', 'repeated content');
|
|
expect(second.id).toBe(first.id);
|
|
const row = frames.getById(first.id);
|
|
expect(row?.access_count).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('update() rewrites content and importance and keeps FTS in sync', () => {
|
|
const iframe = frames.createIFrame('gop-test', 'original');
|
|
const updated = frames.update(iframe.id, 'revised', 'critical');
|
|
expect(updated?.content).toBe('revised');
|
|
expect(updated?.importance).toBe('critical');
|
|
|
|
const ftsHit = db
|
|
.getDatabase()
|
|
.prepare('SELECT rowid FROM memory_frames_fts WHERE memory_frames_fts MATCH ?')
|
|
.all('revised') as { rowid: number }[];
|
|
expect(ftsHit.map((r) => r.rowid)).toContain(iframe.id);
|
|
});
|
|
|
|
it('update() preserves all indexes when only importance changes', () => {
|
|
const iframe = frames.createIFrame('gop-test', 'indexed content', 'normal');
|
|
const raw = db.getDatabase();
|
|
const vector = new Uint8Array(new Float32Array(1024).fill(0.1).buffer);
|
|
raw.prepare(`INSERT INTO memory_frames_vec (rowid, embedding) VALUES (${iframe.id}, ?)`)
|
|
.run(vector);
|
|
const chunk = raw.prepare(
|
|
'INSERT INTO memory_frame_chunks (frame_id, chunk_idx, content, char_start, char_end) VALUES (?, 0, ?, 0, ?)',
|
|
).run(iframe.id, 'indexed chunk', 'indexed chunk'.length);
|
|
const chunkId = Number(chunk.lastInsertRowid);
|
|
raw.prepare(`INSERT INTO memory_frame_chunks_vec (rowid, embedding) VALUES (${chunkId}, ?)`)
|
|
.run(vector);
|
|
|
|
const updated = frames.update(iframe.id, iframe.content, 'critical');
|
|
|
|
expect(updated?.importance).toBe('critical');
|
|
expect(updated?.content_hash).toBe(iframe.content_hash);
|
|
expect((raw.prepare('SELECT COUNT(*) AS n FROM memory_frames_fts WHERE rowid = ?').get(iframe.id) as { n: number }).n).toBe(1);
|
|
expect((raw.prepare('SELECT COUNT(*) AS n FROM memory_frames_vec WHERE rowid = ?').get(iframe.id) as { n: number }).n).toBe(1);
|
|
expect((raw.prepare('SELECT COUNT(*) AS n FROM memory_frame_chunks WHERE id = ?').get(chunkId) as { n: number }).n).toBe(1);
|
|
expect((raw.prepare('SELECT COUNT(*) AS n FROM memory_frame_chunks_vec WHERE rowid = ?').get(chunkId) as { n: number }).n).toBe(1);
|
|
});
|
|
|
|
it('runInTransaction acquires the write lock before the first statement', () => {
|
|
const competing = new MindDB(dbPath);
|
|
competing.getDatabase().pragma('busy_timeout = 1');
|
|
try {
|
|
frames.runInTransaction(() => {
|
|
expect(db.getDatabase().inTransaction).toBe(true);
|
|
expect(() => competing.getDatabase().prepare(
|
|
"UPDATE sessions SET summary = 'competing write' WHERE gop_id = 'gop-test'",
|
|
).run()).toThrow(/locked/i);
|
|
});
|
|
} finally {
|
|
competing.close();
|
|
}
|
|
});
|
|
|
|
it('runInTransaction uses a nested savepoint without retrying the inner closure', () => {
|
|
const retry = vi.spyOn(db, 'runWithBusyRetry');
|
|
let outerId = 0;
|
|
expect(() => frames.runInTransaction(() => {
|
|
outerId = frames.createIFrame('gop-test', 'outer transaction frame').id;
|
|
expect(() => frames.runInTransaction(() => {
|
|
frames.createIFrame('gop-test', 'inner transaction frame');
|
|
throw new Error('rollback inner');
|
|
})).toThrow('rollback inner');
|
|
expect(db.getDatabase().prepare(
|
|
"SELECT COUNT(*) AS n FROM memory_frames WHERE content = 'inner transaction frame'",
|
|
).get()).toEqual({ n: 0 });
|
|
})).not.toThrow();
|
|
|
|
expect(frames.getById(outerId)?.content).toBe('outer transaction frame');
|
|
expect(retry).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('delete() removes the row, FTS entry, and clears back-references', () => {
|
|
const base = frames.createIFrame('gop-test', 'base');
|
|
const dependent = frames.createPFrame('gop-test', 'dependent', base.id);
|
|
|
|
const ok = frames.delete(base.id);
|
|
expect(ok).toBe(true);
|
|
expect(frames.getById(base.id)).toBeUndefined();
|
|
|
|
const survivor = frames.getById(dependent.id);
|
|
expect(survivor).toBeDefined();
|
|
expect(survivor?.base_frame_id).toBeNull();
|
|
});
|
|
|
|
it('compact() prunes stale temporary frames older than maxTempAgeDays', () => {
|
|
const tempFrame = frames.createIFrame('gop-test', 'ephemeral', 'temporary');
|
|
db.getDatabase()
|
|
.prepare("UPDATE memory_frames SET created_at = datetime('now', '-100 days') WHERE id = ?")
|
|
.run(tempFrame.id);
|
|
|
|
const result = frames.compact(30, 90);
|
|
expect(result.temporaryPruned).toBe(1);
|
|
expect(frames.getById(tempFrame.id)).toBeUndefined();
|
|
});
|
|
|
|
it('getStats() aggregates counts by type and importance', () => {
|
|
frames.createIFrame('gop-test', 'a', 'critical');
|
|
frames.createIFrame('gop-test', 'b', 'normal');
|
|
const base = frames.createIFrame('gop-test', 'c', 'important');
|
|
frames.createPFrame('gop-test', 'd', base.id, 'normal');
|
|
|
|
const stats = frames.getStats();
|
|
expect(stats.total).toBe(4);
|
|
expect(stats.byType.I).toBe(3);
|
|
expect(stats.byType.P).toBe(1);
|
|
expect(stats.byImportance.critical).toBe(1);
|
|
expect(stats.byImportance.important).toBe(1);
|
|
expect(stats.byImportance.normal).toBe(2);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* OQ-6 — provenance-insensitive save-side dedup. The OpenClaw gateway can
|
|
* capture the same turn that a backend tool (Claude Code / Codex) also
|
|
* captures via its own lifecycle hooks, producing two frames with identical
|
|
* bodies but different `[hm session:… src:… event:…] ` prefixes. `findDuplicate`
|
|
* now strips that prefix before hashing, so the two collapse into one stored
|
|
* frame (later writer only bumps `access_count`).
|
|
*
|
|
* Design: docs/superpowers/specs/2026-06-01-openclaw-dedup-design.md
|
|
*/
|
|
describe('FrameStore provenance-insensitive dedup (OQ-6)', () => {
|
|
let dbPath: string;
|
|
let db: MindDB;
|
|
let frames: FrameStore;
|
|
|
|
beforeEach(() => {
|
|
dbPath = join(tmpdir(), `waggle-mind-dedup-test-${Date.now()}-${Math.random()}.mind`);
|
|
db = new MindDB(dbPath);
|
|
db.getDatabase()
|
|
.prepare("INSERT INTO sessions (gop_id, status, started_at) VALUES ('gop-test', 'active', datetime('now'))")
|
|
.run();
|
|
frames = new FrameStore(db);
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
if (existsSync(dbPath)) rmSync(dbPath);
|
|
for (const suffix of ['-shm', '-wal']) {
|
|
if (existsSync(dbPath + suffix)) rmSync(dbPath + suffix);
|
|
}
|
|
});
|
|
|
|
it('collapses two same-body captures from different sources into one frame', () => {
|
|
const openclaw = frames.createIFrame(
|
|
'gop-test',
|
|
'[hm session:openclaw-gateway:c1 src:openclaw event:stop] the shared turn body',
|
|
);
|
|
const backend = frames.createIFrame(
|
|
'gop-test',
|
|
'[hm session:s2 src:claude-code event:stop] the shared turn body',
|
|
);
|
|
|
|
// Second (backend) capture returns the FIRST (openclaw) frame — no new row.
|
|
expect(backend.id).toBe(openclaw.id);
|
|
expect(frames.getStats().total).toBe(1);
|
|
|
|
// First writer's frame is kept verbatim, with its provenance intact.
|
|
const row = frames.getById(openclaw.id);
|
|
expect(row?.content).toBe(
|
|
'[hm session:openclaw-gateway:c1 src:openclaw event:stop] the shared turn body',
|
|
);
|
|
// The later duplicate bumped access_count.
|
|
expect(row?.access_count).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('keeps two frames when the bodies differ despite matching prefixes shape', () => {
|
|
const a = frames.createIFrame(
|
|
'gop-test',
|
|
'[hm session:openclaw-gateway:c1 src:openclaw event:stop] body one',
|
|
);
|
|
const b = frames.createIFrame(
|
|
'gop-test',
|
|
'[hm session:s2 src:claude-code event:stop] body two',
|
|
);
|
|
|
|
expect(b.id).not.toBe(a.id);
|
|
expect(frames.getStats().total).toBe(2);
|
|
});
|
|
|
|
it('regression: non-prefixed identical bodies still dedup exactly as before', () => {
|
|
const first = frames.createIFrame('gop-test', 'plain harvested body');
|
|
const second = frames.createIFrame('gop-test', 'plain harvested body');
|
|
|
|
expect(second.id).toBe(first.id);
|
|
expect(frames.getStats().total).toBe(1);
|
|
expect(frames.getById(first.id)?.access_count).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('collapses a prefixed capture against an existing non-prefixed body', () => {
|
|
const plain = frames.createIFrame('gop-test', 'the shared turn body');
|
|
const prefixed = frames.createIFrame(
|
|
'gop-test',
|
|
'[hm session:openclaw-gateway:c1 src:openclaw event:stop] the shared turn body',
|
|
);
|
|
|
|
expect(prefixed.id).toBe(plain.id);
|
|
expect(frames.getStats().total).toBe(1);
|
|
// First writer (the plain body) is preserved verbatim.
|
|
expect(frames.getById(plain.id)?.content).toBe('the shared turn body');
|
|
});
|
|
|
|
it('dedups duplicates beyond the old 500-frame recency window (oss-drift D3)', () => {
|
|
// Pre-D3 this test asserted the OPPOSITE: findDuplicate scanned only the
|
|
// last 500 frames, so a body buried under 500 fillers re-inserted as a new
|
|
// row. The indexed content_hash lookup has no recency window — the old
|
|
// limitation (and the old assertion) is gone.
|
|
const original = frames.createIFrame('gop-test', 'recency-bound body');
|
|
for (let i = 0; i < 500; i++) {
|
|
frames.createIFrame('gop-test', `filler-${i}`);
|
|
}
|
|
const reinserted = frames.createIFrame('gop-test', 'recency-bound body');
|
|
|
|
expect(reinserted.id).toBe(original.id);
|
|
// 501 total: 1 deduped body + 500 fillers.
|
|
expect(frames.getStats().total).toBe(501);
|
|
});
|
|
});
|
|
|
|
describe('stripHmPrefix helper', () => {
|
|
it('removes a well-formed hive-mind metadata prefix', () => {
|
|
expect(
|
|
stripHmPrefix('[hm session:openclaw-gateway:c1 src:openclaw event:stop] the body'),
|
|
).toBe('the body');
|
|
});
|
|
|
|
it('leaves prefix-less content untouched (no-op)', () => {
|
|
expect(stripHmPrefix('plain harvested body')).toBe('plain harvested body');
|
|
});
|
|
|
|
it('does not over-strip a body that merely contains brackets later', () => {
|
|
expect(stripHmPrefix('do X [note] then Y')).toBe('do X [note] then Y');
|
|
});
|
|
|
|
it('strips only the leading prefix, preserving later brackets in the body', () => {
|
|
expect(
|
|
stripHmPrefix('[hm src:claude-code event:stop] do X [note] then Y'),
|
|
).toBe('do X [note] then Y');
|
|
});
|
|
});
|