This commit is contained in:
5
packages/weaver/LICENSE
Normal file
5
packages/weaver/LICENSE
Normal file
@@ -0,0 +1,5 @@
|
||||
Copyright (c) 2026 Marko Markovic. All rights reserved.
|
||||
|
||||
This software is proprietary and confidential. Unauthorized copying,
|
||||
modification, distribution, or use of this software, via any medium,
|
||||
is strictly prohibited.
|
||||
25
packages/weaver/package.json
Normal file
25
packages/weaver/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@waggle/weaver",
|
||||
"version": "0.1.0",
|
||||
"description": "Waggle weaver — Memory consolidation engine",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@waggle/core": "*"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
255
packages/weaver/src/consolidation.ts
Normal file
255
packages/weaver/src/consolidation.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import type { MindDB, FrameStore, MemoryFrame, Importance, SessionStore, KnowledgeGraph } from '@waggle/core';
|
||||
|
||||
const IMPORTANCE_UPGRADE: Record<string, Importance> = {
|
||||
temporary: 'normal',
|
||||
normal: 'important',
|
||||
important: 'critical',
|
||||
};
|
||||
|
||||
export class MemoryWeaver {
|
||||
private db: MindDB;
|
||||
private frames: FrameStore;
|
||||
private sessions: SessionStore;
|
||||
|
||||
constructor(db: MindDB, frames: FrameStore, sessions: SessionStore) {
|
||||
this.db = db;
|
||||
this.frames = frames;
|
||||
this.sessions = sessions;
|
||||
}
|
||||
|
||||
consolidateGop(gopId: string): MemoryFrame | null {
|
||||
const state = this.frames.reconstructState(gopId);
|
||||
if (!state.iframe || state.pframes.length === 0) return null;
|
||||
|
||||
// Merge I-frame + P-frames into consolidated content
|
||||
const parts = [state.iframe.content, ...state.pframes.map(p => p.content)];
|
||||
const mergedContent = parts.join('\n---\n');
|
||||
|
||||
// Create new consolidated I-frame
|
||||
const consolidated = this.frames.createIFrame(gopId, mergedContent, 'normal');
|
||||
|
||||
// Mark old P-frames as deprecated
|
||||
const raw = this.db.getDatabase();
|
||||
const pframeIds = state.pframes.map(p => p.id);
|
||||
const placeholders = pframeIds.map(() => '?').join(',');
|
||||
raw.prepare(
|
||||
`UPDATE memory_frames SET importance = 'deprecated' WHERE id IN (${placeholders})`
|
||||
).run(...pframeIds);
|
||||
|
||||
return consolidated;
|
||||
}
|
||||
|
||||
decayFrames(): number {
|
||||
const raw = this.db.getDatabase();
|
||||
|
||||
// Delete deprecated frames with zero access count
|
||||
// First get the IDs for FTS cleanup
|
||||
const toDelete = raw.prepare(
|
||||
"SELECT id FROM memory_frames WHERE importance = 'deprecated' AND access_count = 0"
|
||||
).all() as { id: number }[];
|
||||
|
||||
if (toDelete.length === 0) return 0;
|
||||
|
||||
const ids = toDelete.map(r => r.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
|
||||
// Delete from FTS index
|
||||
raw.prepare(
|
||||
`DELETE FROM memory_frames_fts WHERE rowid IN (${placeholders})`
|
||||
).run(...ids);
|
||||
|
||||
// Delete the frames
|
||||
const result = raw.prepare(
|
||||
`DELETE FROM memory_frames WHERE id IN (${placeholders})`
|
||||
).run(...ids);
|
||||
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
strengthenFrames(tempThreshold = 10, normalThreshold = 25): number {
|
||||
const raw = this.db.getDatabase();
|
||||
let upgraded = 0;
|
||||
|
||||
// Upgrade temporary → normal
|
||||
const tempResult = raw.prepare(`
|
||||
UPDATE memory_frames SET importance = 'normal'
|
||||
WHERE importance = 'temporary' AND access_count >= ?
|
||||
`).run(tempThreshold);
|
||||
upgraded += tempResult.changes;
|
||||
|
||||
// Upgrade normal → important
|
||||
const normalResult = raw.prepare(`
|
||||
UPDATE memory_frames SET importance = 'important'
|
||||
WHERE importance = 'normal' AND access_count >= ?
|
||||
`).run(normalThreshold);
|
||||
upgraded += normalResult.changes;
|
||||
|
||||
return upgraded;
|
||||
}
|
||||
|
||||
createDailySummary(gopIds: string[]): MemoryFrame | null {
|
||||
if (gopIds.length === 0) return null;
|
||||
|
||||
const allContent: string[] = [];
|
||||
for (const gopId of gopIds) {
|
||||
const gopFrames = this.frames.getGopFrames(gopId);
|
||||
for (const frame of gopFrames) {
|
||||
if (frame.frame_type === 'I' || frame.frame_type === 'P') {
|
||||
allContent.push(frame.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allContent.length === 0) return null;
|
||||
|
||||
// Create a summary session
|
||||
const summarySession = this.sessions.create('daily-summary');
|
||||
const summaryContent = allContent.join('\n---\n');
|
||||
return this.frames.createIFrame(summarySession.gop_id, summaryContent, 'important');
|
||||
}
|
||||
|
||||
archiveClosedSessions(): number {
|
||||
const raw = this.db.getDatabase();
|
||||
const result = raw.prepare(
|
||||
"UPDATE sessions SET status = 'archived' WHERE status = 'closed'"
|
||||
).run();
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecate temporary frames older than maxAgeDays with low access count.
|
||||
* Returns the number of frames deprecated.
|
||||
*/
|
||||
decayByAge(maxAgeDays: number, maxAccessCount = 2): number {
|
||||
const raw = this.db.getDatabase();
|
||||
const result = raw.prepare(`
|
||||
UPDATE memory_frames SET importance = 'deprecated'
|
||||
WHERE importance = 'temporary'
|
||||
AND access_count <= ?
|
||||
AND created_at <= datetime('now', '-' || ? || ' days')
|
||||
`).run(maxAccessCount, maxAgeDays);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find frames that share entity names (from the knowledge graph) in their content,
|
||||
* and create B-frame links between them.
|
||||
* Returns the number of B-frames created.
|
||||
*/
|
||||
linkRelatedFrames(kg: KnowledgeGraph): number {
|
||||
const raw = this.db.getDatabase();
|
||||
|
||||
// Get all active entities from the knowledge graph
|
||||
const entities = raw.prepare(
|
||||
'SELECT id, name FROM knowledge_entities WHERE valid_to IS NULL'
|
||||
).all() as { id: number; name: string }[];
|
||||
|
||||
if (entities.length === 0) return 0;
|
||||
|
||||
// Get all non-deprecated, non-B frames
|
||||
const allFrames = raw.prepare(
|
||||
"SELECT id, gop_id, content FROM memory_frames WHERE importance != 'deprecated' AND frame_type != 'B'"
|
||||
).all() as { id: number; gop_id: string; content: string }[];
|
||||
|
||||
if (allFrames.length < 2) return 0;
|
||||
|
||||
// Build a map: entity name → frame IDs that mention it
|
||||
const entityToFrames = new Map<string, Set<number>>();
|
||||
for (const entity of entities) {
|
||||
const nameLower = entity.name.toLowerCase();
|
||||
const matchingFrameIds = new Set<number>();
|
||||
for (const frame of allFrames) {
|
||||
if (frame.content.toLowerCase().includes(nameLower)) {
|
||||
matchingFrameIds.add(frame.id);
|
||||
}
|
||||
}
|
||||
if (matchingFrameIds.size >= 2) {
|
||||
entityToFrames.set(entity.name, matchingFrameIds);
|
||||
}
|
||||
}
|
||||
|
||||
// For each entity with 2+ frames, create a B-frame linking them
|
||||
// Track already-linked pairs to avoid duplicates
|
||||
const linkedPairs = new Set<string>();
|
||||
let created = 0;
|
||||
|
||||
for (const [entityName, frameIds] of entityToFrames) {
|
||||
const ids = Array.from(frameIds);
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const pairKey = `${Math.min(ids[i], ids[j])}:${Math.max(ids[i], ids[j])}`;
|
||||
if (linkedPairs.has(pairKey)) continue;
|
||||
linkedPairs.add(pairKey);
|
||||
|
||||
// Find the gop_id of the base frame
|
||||
const baseFrame = allFrames.find(f => f.id === ids[i]);
|
||||
if (!baseFrame) continue;
|
||||
|
||||
this.frames.createBFrame(
|
||||
baseFrame.gop_id,
|
||||
`Shared entity: ${entityName}`,
|
||||
ids[i],
|
||||
[ids[j]]
|
||||
);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distill session content into a durable memory frame.
|
||||
* Takes pre-extracted session summary and key points, creates an important frame
|
||||
* that persists across consolidation cycles.
|
||||
*/
|
||||
distillSessionContent(sessionDate: string, summary: string, keyPoints: string[]): MemoryFrame {
|
||||
const parts = [`Session (${sessionDate}): ${summary}`];
|
||||
if (keyPoints.length > 0) {
|
||||
parts.push('Key points: ' + keyPoints.join('; '));
|
||||
}
|
||||
const content = parts.join('. ');
|
||||
|
||||
// Replace-on-update: re-distilling the same session (same date+summary,
|
||||
// evolving key points) must update the one distilled frame. createIFrame's
|
||||
// exact-content dedup can't catch the drifting key-points tail — every
|
||||
// cron re-run appended another near-identical "Session (…)" frame.
|
||||
this.frames.deleteByContentPrefix(`Session (${sessionDate}): ${summary}`);
|
||||
|
||||
// Create a session for the distilled content (or reuse an active one)
|
||||
const active = this.sessions.getActive();
|
||||
let gopId: string;
|
||||
if (active.length > 0) {
|
||||
gopId = active[0].gop_id;
|
||||
} else {
|
||||
gopId = this.sessions.create('distilled').gop_id;
|
||||
}
|
||||
|
||||
return this.frames.createIFrame(gopId, content, 'important');
|
||||
}
|
||||
|
||||
consolidateProject(projectId: string): MemoryFrame | null {
|
||||
const projectSessions = this.sessions.getByProject(projectId);
|
||||
const closedSessions = projectSessions.filter(s => s.status === 'closed' || s.status === 'archived');
|
||||
|
||||
if (closedSessions.length === 0) return null;
|
||||
|
||||
const allContent: string[] = [];
|
||||
for (const session of closedSessions) {
|
||||
const latestI = this.frames.getLatestIFrame(session.gop_id);
|
||||
if (latestI) {
|
||||
allContent.push(`[${session.gop_id}] ${latestI.content}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (allContent.length === 0) return null;
|
||||
|
||||
const consolidationSession = this.sessions.create(projectId);
|
||||
return this.frames.createIFrame(
|
||||
consolidationSession.gop_id,
|
||||
allContent.join('\n---\n'),
|
||||
'important'
|
||||
);
|
||||
}
|
||||
}
|
||||
3
packages/weaver/src/index.ts
Normal file
3
packages/weaver/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { MemoryWeaver } from './consolidation.js';
|
||||
export { extractSessionSkills } from './skill-extractor.js';
|
||||
export type { SessionEntry, ExtractedSkill } from './skill-extractor.js';
|
||||
48
packages/weaver/src/skill-extractor.ts
Normal file
48
packages/weaver/src/skill-extractor.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export interface SessionEntry {
|
||||
role: string;
|
||||
content: string;
|
||||
tools_used?: string[];
|
||||
}
|
||||
|
||||
export interface ExtractedSkill {
|
||||
name: string;
|
||||
description: string;
|
||||
tools: string[];
|
||||
frequency: number;
|
||||
}
|
||||
|
||||
export function extractSessionSkills(entries: SessionEntry[]): ExtractedSkill[] {
|
||||
// Group user→agent pairs by tool combo key
|
||||
const toolCombos = new Map<string, { tools: string[]; count: number; prompts: string[] }>();
|
||||
|
||||
for (let i = 0; i < entries.length - 1; i++) {
|
||||
const user = entries[i];
|
||||
const agent = entries[i + 1];
|
||||
if (user.role !== 'user' || agent.role !== 'agent') continue;
|
||||
if (!agent.tools_used || agent.tools_used.length === 0) continue;
|
||||
|
||||
const key = [...agent.tools_used].sort().join('+');
|
||||
const existing = toolCombos.get(key);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
existing.prompts.push(user.content);
|
||||
} else {
|
||||
toolCombos.set(key, { tools: agent.tools_used, count: 1, prompts: [user.content] });
|
||||
}
|
||||
}
|
||||
|
||||
// Return patterns with frequency >= 2, or multi-tool at frequency 1
|
||||
const skills: ExtractedSkill[] = [];
|
||||
for (const [, combo] of toolCombos) {
|
||||
if (combo.count >= 2 || combo.tools.length >= 2) {
|
||||
skills.push({
|
||||
name: combo.tools.sort().join('+'),
|
||||
description: `Pattern: ${combo.prompts[0]}`,
|
||||
tools: combo.tools,
|
||||
frequency: combo.count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
167
packages/weaver/tests/consolidation-enhanced.test.ts
Normal file
167
packages/weaver/tests/consolidation-enhanced.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { MindDB, FrameStore, SessionStore, KnowledgeGraph } from '@waggle/core';
|
||||
import { MemoryWeaver } from '../src/consolidation.js';
|
||||
|
||||
describe('Enhanced Memory Consolidation', () => {
|
||||
let db: MindDB;
|
||||
let frames: FrameStore;
|
||||
let sessions: SessionStore;
|
||||
let kg: KnowledgeGraph;
|
||||
let weaver: MemoryWeaver;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new MindDB(':memory:');
|
||||
frames = new FrameStore(db);
|
||||
sessions = new SessionStore(db);
|
||||
kg = new KnowledgeGraph(db);
|
||||
weaver = new MemoryWeaver(db, frames, sessions);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('decayByAge — time-based decay', () => {
|
||||
it('deprecates temporary frames older than maxAgeDays with low access', () => {
|
||||
const session = sessions.create();
|
||||
const raw = db.getDatabase();
|
||||
|
||||
// Create a temporary frame and backdate it to 45 days ago
|
||||
const oldFrame = frames.createIFrame(session.gop_id, 'Old temporary note', 'temporary');
|
||||
raw.prepare(
|
||||
"UPDATE memory_frames SET created_at = datetime('now', '-45 days') WHERE id = ?"
|
||||
).run(oldFrame.id);
|
||||
|
||||
// Create a recent temporary frame (should NOT be deprecated)
|
||||
frames.createIFrame(session.gop_id, 'Recent temporary note', 'temporary');
|
||||
|
||||
// Create an old normal frame (should NOT be deprecated — wrong importance)
|
||||
const normalFrame = frames.createIFrame(session.gop_id, 'Old normal note', 'normal');
|
||||
raw.prepare(
|
||||
"UPDATE memory_frames SET created_at = datetime('now', '-45 days') WHERE id = ?"
|
||||
).run(normalFrame.id);
|
||||
|
||||
const deprecated = weaver.decayByAge(30);
|
||||
expect(deprecated).toBe(1);
|
||||
|
||||
// Verify the old temporary frame is now deprecated
|
||||
const updated = frames.getById(oldFrame.id);
|
||||
expect(updated!.importance).toBe('deprecated');
|
||||
|
||||
// Verify the recent temporary frame is still temporary
|
||||
const allFrames = frames.getGopFrames(session.gop_id);
|
||||
const stillTemp = allFrames.filter(f => f.importance === 'temporary');
|
||||
expect(stillTemp).toHaveLength(1);
|
||||
expect(stillTemp[0].content).toBe('Recent temporary note');
|
||||
});
|
||||
|
||||
it('does not deprecate old temporary frames with high access count', () => {
|
||||
const session = sessions.create();
|
||||
const raw = db.getDatabase();
|
||||
|
||||
const frame = frames.createIFrame(session.gop_id, 'Frequently accessed old temp', 'temporary');
|
||||
raw.prepare(
|
||||
"UPDATE memory_frames SET created_at = datetime('now', '-45 days') WHERE id = ?"
|
||||
).run(frame.id);
|
||||
|
||||
// Give it many accesses
|
||||
for (let i = 0; i < 5; i++) frames.touch(frame.id);
|
||||
|
||||
const deprecated = weaver.decayByAge(30);
|
||||
expect(deprecated).toBe(0);
|
||||
|
||||
const updated = frames.getById(frame.id);
|
||||
expect(updated!.importance).toBe('temporary');
|
||||
});
|
||||
|
||||
it('returns 0 when no frames match criteria', () => {
|
||||
const session = sessions.create();
|
||||
frames.createIFrame(session.gop_id, 'Normal frame', 'normal');
|
||||
frames.createIFrame(session.gop_id, 'Important frame', 'important');
|
||||
|
||||
const deprecated = weaver.decayByAge(30);
|
||||
expect(deprecated).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkRelatedFrames — entity-aware linking', () => {
|
||||
it('creates B-frames linking frames that share entities', () => {
|
||||
const s1 = sessions.create('project:test');
|
||||
const s2 = sessions.create('project:test');
|
||||
|
||||
// Create frames mentioning the same entity
|
||||
frames.createIFrame(s1.gop_id, 'Working on the React frontend today');
|
||||
frames.createIFrame(s2.gop_id, 'React component performance optimization');
|
||||
|
||||
// Create an entity in the knowledge graph
|
||||
kg.createEntity('technology', 'React', { category: 'frontend' });
|
||||
|
||||
const linked = weaver.linkRelatedFrames(kg);
|
||||
expect(linked).toBe(1);
|
||||
|
||||
// Verify a B-frame was created
|
||||
const gopFrames = frames.getGopFrames(s1.gop_id);
|
||||
const bframes = gopFrames.filter(f => f.frame_type === 'B');
|
||||
expect(bframes).toHaveLength(1);
|
||||
|
||||
// Verify B-frame content references the shared entity
|
||||
const parsed = JSON.parse(bframes[0].content);
|
||||
expect(parsed.description).toContain('React');
|
||||
expect(parsed.references).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not create B-frames when no shared entities exist', () => {
|
||||
const s1 = sessions.create('project:test');
|
||||
const s2 = sessions.create('project:test');
|
||||
|
||||
frames.createIFrame(s1.gop_id, 'Working on the frontend');
|
||||
frames.createIFrame(s2.gop_id, 'Backend database migration');
|
||||
|
||||
kg.createEntity('technology', 'React', { category: 'frontend' });
|
||||
|
||||
// Only one frame mentions React, so no link should be created
|
||||
const linked = weaver.linkRelatedFrames(kg);
|
||||
expect(linked).toBe(0);
|
||||
});
|
||||
|
||||
it('handles multiple shared entities without duplicate B-frames', () => {
|
||||
const s1 = sessions.create('project:test');
|
||||
const s2 = sessions.create('project:test');
|
||||
|
||||
// Both frames mention React and TypeScript
|
||||
frames.createIFrame(s1.gop_id, 'Building React components with TypeScript');
|
||||
frames.createIFrame(s2.gop_id, 'React + TypeScript best practices');
|
||||
|
||||
kg.createEntity('technology', 'React', {});
|
||||
kg.createEntity('technology', 'TypeScript', {});
|
||||
|
||||
const linked = weaver.linkRelatedFrames(kg);
|
||||
// Only 1 B-frame for the pair, not 2 (one per entity)
|
||||
expect(linked).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0 when no entities exist in the knowledge graph', () => {
|
||||
const session = sessions.create();
|
||||
frames.createIFrame(session.gop_id, 'Some content');
|
||||
|
||||
const linked = weaver.linkRelatedFrames(kg);
|
||||
expect(linked).toBe(0);
|
||||
});
|
||||
|
||||
it('links three frames sharing an entity with correct B-frame count', () => {
|
||||
const s1 = sessions.create('project:multi');
|
||||
const s2 = sessions.create('project:multi');
|
||||
const s3 = sessions.create('project:multi');
|
||||
|
||||
frames.createIFrame(s1.gop_id, 'SQLite schema design');
|
||||
frames.createIFrame(s2.gop_id, 'SQLite performance tuning');
|
||||
frames.createIFrame(s3.gop_id, 'SQLite backup strategy');
|
||||
|
||||
kg.createEntity('technology', 'SQLite', {});
|
||||
|
||||
const linked = weaver.linkRelatedFrames(kg);
|
||||
// 3 frames → 3 pairs: (1,2), (1,3), (2,3)
|
||||
expect(linked).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
254
packages/weaver/tests/consolidation.test.ts
Normal file
254
packages/weaver/tests/consolidation.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { MindDB, FrameStore, type Importance, SessionStore } from '@waggle/core';
|
||||
import { MemoryWeaver } from '../src/consolidation.js';
|
||||
|
||||
describe('Memory Weaver (Consolidation)', () => {
|
||||
let db: MindDB;
|
||||
let frames: FrameStore;
|
||||
let sessions: SessionStore;
|
||||
let weaver: MemoryWeaver;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new MindDB(':memory:');
|
||||
frames = new FrameStore(db);
|
||||
sessions = new SessionStore(db);
|
||||
weaver = new MemoryWeaver(db, frames, sessions);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('P-frame merging into consolidated I-frame', () => {
|
||||
it('merges P-frames into a new I-frame within a GOP', () => {
|
||||
const session = sessions.create();
|
||||
const iframe = frames.createIFrame(session.gop_id, JSON.stringify({ state: 'initial', items: ['a'] }));
|
||||
frames.createPFrame(session.gop_id, JSON.stringify({ added: 'b' }), iframe.id);
|
||||
frames.createPFrame(session.gop_id, JSON.stringify({ added: 'c' }), iframe.id);
|
||||
frames.createPFrame(session.gop_id, JSON.stringify({ added: 'd' }), iframe.id);
|
||||
|
||||
const consolidated = weaver.consolidateGop(session.gop_id);
|
||||
expect(consolidated).toBeDefined();
|
||||
expect(consolidated!.frame_type).toBe('I');
|
||||
|
||||
// Old P-frames should be marked deprecated
|
||||
const gopFrames = frames.getGopFrames(session.gop_id);
|
||||
const deprecated = gopFrames.filter(f => f.importance === 'deprecated' && f.frame_type === 'P');
|
||||
expect(deprecated).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('skips consolidation if no P-frames exist', () => {
|
||||
const session = sessions.create();
|
||||
frames.createIFrame(session.gop_id, 'Just a keyframe');
|
||||
const result = weaver.consolidateGop(session.gop_id);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('consolidated I-frame contains merged content', () => {
|
||||
const session = sessions.create();
|
||||
const iframe = frames.createIFrame(session.gop_id, 'Base state');
|
||||
frames.createPFrame(session.gop_id, 'Delta 1: user asked about weather', iframe.id);
|
||||
frames.createPFrame(session.gop_id, 'Delta 2: showed forecast', iframe.id);
|
||||
|
||||
const consolidated = weaver.consolidateGop(session.gop_id);
|
||||
expect(consolidated!.content).toContain('Base state');
|
||||
expect(consolidated!.content).toContain('Delta 1');
|
||||
expect(consolidated!.content).toContain('Delta 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Decay: remove deprecated frames', () => {
|
||||
it('removes deprecated frames with zero access', () => {
|
||||
const session = sessions.create();
|
||||
frames.createIFrame(session.gop_id, 'Active frame', 'normal');
|
||||
frames.createIFrame(session.gop_id, 'Deprecated unused', 'deprecated');
|
||||
frames.createIFrame(session.gop_id, 'Deprecated but accessed', 'deprecated');
|
||||
|
||||
// Touch the third frame to give it accesses
|
||||
const gopFrames = frames.getGopFrames(session.gop_id);
|
||||
const accessedFrame = gopFrames[2];
|
||||
frames.touch(accessedFrame.id);
|
||||
frames.touch(accessedFrame.id);
|
||||
|
||||
const removed = weaver.decayFrames();
|
||||
expect(removed).toBe(1); // Only the zero-access deprecated frame
|
||||
});
|
||||
|
||||
it('does not remove non-deprecated frames', () => {
|
||||
const session = sessions.create();
|
||||
frames.createIFrame(session.gop_id, 'Normal frame', 'normal');
|
||||
frames.createIFrame(session.gop_id, 'Temporary frame', 'temporary');
|
||||
|
||||
const removed = weaver.decayFrames();
|
||||
expect(removed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Strengthen: upgrade frequently accessed frames', () => {
|
||||
it('upgrades temporary frames to normal after threshold accesses', () => {
|
||||
const session = sessions.create();
|
||||
const frame = frames.createIFrame(session.gop_id, 'Getting popular', 'temporary');
|
||||
|
||||
// Simulate many accesses
|
||||
for (let i = 0; i < 10; i++) frames.touch(frame.id);
|
||||
|
||||
const upgraded = weaver.strengthenFrames(10);
|
||||
expect(upgraded).toBe(1);
|
||||
|
||||
const updated = frames.getById(frame.id);
|
||||
expect(updated!.importance).toBe('normal');
|
||||
});
|
||||
|
||||
it('upgrades normal frames to important after higher threshold', () => {
|
||||
const session = sessions.create();
|
||||
const frame = frames.createIFrame(session.gop_id, 'Very popular', 'normal');
|
||||
|
||||
for (let i = 0; i < 25; i++) frames.touch(frame.id);
|
||||
|
||||
const upgraded = weaver.strengthenFrames(10, 25);
|
||||
expect(upgraded).toBe(1);
|
||||
|
||||
const updated = frames.getById(frame.id);
|
||||
expect(updated!.importance).toBe('important');
|
||||
});
|
||||
|
||||
it('does not upgrade already critical frames', () => {
|
||||
const session = sessions.create();
|
||||
const frame = frames.createIFrame(session.gop_id, 'Already critical', 'critical');
|
||||
for (let i = 0; i < 50; i++) frames.touch(frame.id);
|
||||
|
||||
const upgraded = weaver.strengthenFrames(10);
|
||||
expect(upgraded).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Daily summary', () => {
|
||||
it('creates a compressed I-frame from day activity', () => {
|
||||
const session = sessions.create('project:daily');
|
||||
const iframe = frames.createIFrame(session.gop_id, 'Morning start');
|
||||
frames.createPFrame(session.gop_id, 'Checked emails', iframe.id);
|
||||
frames.createPFrame(session.gop_id, 'Had meeting with team', iframe.id);
|
||||
frames.createPFrame(session.gop_id, 'Reviewed PRs', iframe.id);
|
||||
frames.createPFrame(session.gop_id, 'Deployed v2.1', iframe.id);
|
||||
|
||||
const summary = weaver.createDailySummary([session.gop_id]);
|
||||
expect(summary).toBeDefined();
|
||||
expect(summary!.frame_type).toBe('I');
|
||||
expect(summary!.importance).toBe('important');
|
||||
expect(summary!.content).toContain('Morning start');
|
||||
});
|
||||
|
||||
it('returns null when no sessions provided', () => {
|
||||
const summary = weaver.createDailySummary([]);
|
||||
expect(summary).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session archival', () => {
|
||||
it('closes and archives old sessions', () => {
|
||||
const s1 = sessions.create();
|
||||
const s2 = sessions.create();
|
||||
frames.createIFrame(s1.gop_id, 'S1 content');
|
||||
frames.createIFrame(s2.gop_id, 'S2 content');
|
||||
|
||||
// Close s1
|
||||
sessions.close(s1.gop_id, 'Done');
|
||||
|
||||
const archived = weaver.archiveClosedSessions();
|
||||
expect(archived).toBe(1);
|
||||
|
||||
const s1Updated = sessions.getByGopId(s1.gop_id);
|
||||
expect(s1Updated!.status).toBe('archived');
|
||||
});
|
||||
|
||||
it('does not archive active sessions', () => {
|
||||
sessions.create();
|
||||
const archived = weaver.archiveClosedSessions();
|
||||
expect(archived).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-GOP consolidation', () => {
|
||||
it('merges related GOPs from same project', () => {
|
||||
const s1 = sessions.create('project:waggle');
|
||||
const s2 = sessions.create('project:waggle');
|
||||
|
||||
frames.createIFrame(s1.gop_id, 'Session 1: Designed the schema');
|
||||
frames.createIFrame(s2.gop_id, 'Session 2: Implemented the schema');
|
||||
|
||||
sessions.close(s1.gop_id, 'Schema design complete');
|
||||
sessions.close(s2.gop_id, 'Schema implementation complete');
|
||||
|
||||
const merged = weaver.consolidateProject('project:waggle');
|
||||
expect(merged).toBeDefined();
|
||||
expect(merged!.content).toContain('Session 1');
|
||||
expect(merged!.content).toContain('Session 2');
|
||||
});
|
||||
|
||||
it('returns null for project with no closed sessions', () => {
|
||||
sessions.create('project:empty');
|
||||
const merged = weaver.consolidateProject('project:empty');
|
||||
expect(merged).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session distillation', () => {
|
||||
it('creates a durable memory frame from session summary and key points', () => {
|
||||
const frame = weaver.distillSessionContent(
|
||||
'2026-03-10',
|
||||
'Discussed Q2 marketing strategy',
|
||||
['decided to focus on social media', 'agreed on $50k budget']
|
||||
);
|
||||
|
||||
expect(frame).toBeDefined();
|
||||
expect(frame.frame_type).toBe('I');
|
||||
expect(frame.importance).toBe('important');
|
||||
expect(frame.content).toContain('Session (2026-03-10)');
|
||||
expect(frame.content).toContain('Discussed Q2 marketing strategy');
|
||||
expect(frame.content).toContain('decided to focus on social media');
|
||||
expect(frame.content).toContain('agreed on $50k budget');
|
||||
});
|
||||
|
||||
it('re-distilling the same session replaces the frame instead of duplicating it', () => {
|
||||
weaver.distillSessionContent('2026-03-10', 'Discussed Q2 marketing strategy', ['point A']);
|
||||
weaver.distillSessionContent('2026-03-10', 'Discussed Q2 marketing strategy', ['point A', 'point B']);
|
||||
// A different session the same day must NOT be replaced.
|
||||
weaver.distillSessionContent('2026-03-10', 'Separate standup recap', []);
|
||||
|
||||
const distilled = frames
|
||||
.getRecent(20)
|
||||
.filter((f) => f.content.startsWith('Session (2026-03-10)'));
|
||||
expect(distilled).toHaveLength(2);
|
||||
const strategy = distilled.filter((f) => f.content.includes('Q2 marketing strategy'));
|
||||
expect(strategy).toHaveLength(1);
|
||||
expect(strategy[0].content).toContain('point B');
|
||||
});
|
||||
|
||||
it('creates a frame even without key points', () => {
|
||||
const frame = weaver.distillSessionContent(
|
||||
'2026-03-11',
|
||||
'Quick check-in about project status',
|
||||
[]
|
||||
);
|
||||
|
||||
expect(frame).toBeDefined();
|
||||
expect(frame.content).toContain('Session (2026-03-11)');
|
||||
expect(frame.content).toContain('Quick check-in about project status');
|
||||
expect(frame.content).not.toContain('Key points');
|
||||
});
|
||||
|
||||
it('distilled frames are marked important (survive decay)', () => {
|
||||
const frame = weaver.distillSessionContent(
|
||||
'2026-03-10',
|
||||
'Important strategic discussion',
|
||||
['decided to pivot to enterprise']
|
||||
);
|
||||
|
||||
// Run decay — important frames should NOT be affected
|
||||
weaver.decayByAge(0, 0); // aggressive decay
|
||||
const afterDecay = frames.getById(frame.id);
|
||||
expect(afterDecay).toBeDefined();
|
||||
expect(afterDecay!.importance).toBe('important');
|
||||
});
|
||||
});
|
||||
});
|
||||
54
packages/weaver/tests/skill-extractor.test.ts
Normal file
54
packages/weaver/tests/skill-extractor.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractSessionSkills } from '../src/skill-extractor.js';
|
||||
|
||||
describe('Session Skill Extraction', () => {
|
||||
it('extracts repeated tool usage patterns', () => {
|
||||
const entries = [
|
||||
{ role: 'user', content: 'Find all TypeScript files' },
|
||||
{ role: 'agent', content: 'Found 42 files.', tools_used: ['search_files'] },
|
||||
{ role: 'user', content: 'Find all test files' },
|
||||
{ role: 'agent', content: 'Found 20 files.', tools_used: ['search_files'] },
|
||||
];
|
||||
const skills = extractSessionSkills(entries);
|
||||
expect(skills.length).toBeGreaterThanOrEqual(1);
|
||||
expect(skills.some(s => s.tools.includes('search_files'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty for trivial conversations', () => {
|
||||
const entries = [
|
||||
{ role: 'user', content: 'Hi' },
|
||||
{ role: 'agent', content: 'Hello!', tools_used: [] },
|
||||
];
|
||||
expect(extractSessionSkills(entries)).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts multi-tool patterns at frequency 1', () => {
|
||||
const entries = [
|
||||
{ role: 'user', content: 'Search and edit the config' },
|
||||
{ role: 'agent', content: 'Done.', tools_used: ['search_files', 'edit_file'] },
|
||||
];
|
||||
const skills = extractSessionSkills(entries);
|
||||
expect(skills.length).toBe(1);
|
||||
expect(skills[0].tools).toContain('search_files');
|
||||
expect(skills[0].tools).toContain('edit_file');
|
||||
expect(skills[0].frequency).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores single-tool patterns with frequency 1', () => {
|
||||
const entries = [
|
||||
{ role: 'user', content: 'Read the file' },
|
||||
{ role: 'agent', content: 'Here it is.', tools_used: ['read_file'] },
|
||||
];
|
||||
const skills = extractSessionSkills(entries);
|
||||
expect(skills).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips non user→agent pairs', () => {
|
||||
const entries = [
|
||||
{ role: 'agent', content: 'Welcome!' },
|
||||
{ role: 'agent', content: 'Done.', tools_used: ['search_files'] },
|
||||
];
|
||||
const skills = extractSessionSkills(entries);
|
||||
expect(skills).toEqual([]);
|
||||
});
|
||||
});
|
||||
24
packages/weaver/tsconfig.json
Normal file
24
packages/weaver/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"],
|
||||
"references": [
|
||||
{ "path": "../core" }
|
||||
]
|
||||
}
|
||||
9
packages/weaver/vitest.config.ts
Normal file
9
packages/weaver/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
testTimeout: 30_000,
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user