diff --git a/packages/palace/package.json b/packages/palace/package.json new file mode 100644 index 00000000..44110e34 --- /dev/null +++ b/packages/palace/package.json @@ -0,0 +1,31 @@ +{ + "name": "@bytelyst/palace", + "version": "0.1.0", + "description": "Shared MemPalace primitives — types, cosine similarity, dedup, relevance decay, extraction prompts, KG helpers, wake-up context builder", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/packages/palace/src/__tests__/cosine.test.ts b/packages/palace/src/__tests__/cosine.test.ts new file mode 100644 index 00000000..4d8a4495 --- /dev/null +++ b/packages/palace/src/__tests__/cosine.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { cosineSimilarity, normalizeVector, topKByCosine } from '../cosine.js'; + +describe('cosineSimilarity', () => { + it('returns 1.0 for identical vectors', () => { + expect(cosineSimilarity([1, 0, 0], [1, 0, 0])).toBeCloseTo(1.0); + }); + + it('returns 0.0 for orthogonal vectors', () => { + expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0.0); + }); + + it('returns -1.0 for opposite vectors', () => { + expect(cosineSimilarity([1, 0], [-1, 0])).toBeCloseTo(-1.0); + }); + + it('returns 0 for empty vectors', () => { + expect(cosineSimilarity([], [])).toBe(0); + }); + + it('returns 0 for mismatched dimensions', () => { + expect(cosineSimilarity([1, 2], [1, 2, 3])).toBe(0); + }); + + it('returns 0 for zero vector', () => { + expect(cosineSimilarity([0, 0, 0], [1, 2, 3])).toBe(0); + }); + + it('computes correct similarity for arbitrary vectors', () => { + const a = [1, 2, 3]; + const b = [4, 5, 6]; + // Known value: (4+10+18) / (sqrt(14)*sqrt(77)) ≈ 0.9746 + expect(cosineSimilarity(a, b)).toBeCloseTo(0.9746, 3); + }); +}); + +describe('normalizeVector', () => { + it('normalizes to unit length', () => { + const v = normalizeVector([3, 4]); + const magnitude = Math.sqrt(v[0] ** 2 + v[1] ** 2); + expect(magnitude).toBeCloseTo(1.0); + }); + + it('returns zero vector for zero input', () => { + expect(normalizeVector([0, 0, 0])).toEqual([0, 0, 0]); + }); + + it('preserves direction', () => { + const v = normalizeVector([2, 0, 0]); + expect(v[0]).toBeCloseTo(1); + expect(v[1]).toBeCloseTo(0); + expect(v[2]).toBeCloseTo(0); + }); +}); + +describe('topKByCosine', () => { + const items = [ + { id: 'a', emb: [1, 0, 0] }, + { id: 'b', emb: [0, 1, 0] }, + { id: 'c', emb: [0.9, 0.1, 0] }, + { id: 'd', emb: undefined as number[] | undefined }, + ]; + + it('returns top-K sorted by score', () => { + const results = topKByCosine([1, 0, 0], items, i => i.emb, 2); + expect(results).toHaveLength(2); + expect(results[0].item.id).toBe('a'); + expect(results[0].score).toBeCloseTo(1.0); + expect(results[1].item.id).toBe('c'); + }); + + it('skips items with missing embeddings', () => { + const results = topKByCosine([1, 0, 0], items, i => i.emb, 10); + expect(results).toHaveLength(3); // 'd' skipped + }); + + it('respects minScore filter', () => { + const results = topKByCosine([1, 0, 0], items, i => i.emb, 10, 0.5); + expect(results.every(r => r.score >= 0.5)).toBe(true); + }); + + it('returns empty array for empty items', () => { + const results = topKByCosine([1, 0, 0], [], () => undefined, 5); + expect(results).toEqual([]); + }); +}); diff --git a/packages/palace/src/__tests__/decay.test.ts b/packages/palace/src/__tests__/decay.test.ts new file mode 100644 index 00000000..dff95bdf --- /dev/null +++ b/packages/palace/src/__tests__/decay.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { computeDecayedRelevance, boostRelevance } from '../decay.js'; + +describe('computeDecayedRelevance', () => { + it('returns original relevance for just-created memory', () => { + const now = new Date(); + expect(computeDecayedRelevance(0.8, now.toISOString(), 30, now)).toBeCloseTo(0.8); + }); + + it('halves relevance after exactly one half-life', () => { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 86_400_000); + expect(computeDecayedRelevance(1.0, thirtyDaysAgo.toISOString(), 30, now)).toBeCloseTo(0.5, 2); + }); + + it('quarters relevance after two half-lives', () => { + const now = new Date(); + const sixtyDaysAgo = new Date(now.getTime() - 60 * 86_400_000); + expect(computeDecayedRelevance(1.0, sixtyDaysAgo.toISOString(), 30, now)).toBeCloseTo(0.25, 2); + }); + + it('returns 0 for zero relevance', () => { + expect(computeDecayedRelevance(0, '2024-01-01')).toBe(0); + }); + + it('clamps to [0, 1]', () => { + const now = new Date(); + expect(computeDecayedRelevance(1.5, now.toISOString(), 30, now)).toBeLessThanOrEqual(1); + }); + + it('returns original relevance for zero half-life', () => { + expect(computeDecayedRelevance(0.8, '2024-01-01', 0)).toBe(0.8); + }); + + it('accepts Date objects', () => { + const now = new Date(); + expect(computeDecayedRelevance(0.8, now, 30, now)).toBeCloseTo(0.8); + }); +}); + +describe('boostRelevance', () => { + it('boosts relevance toward 1.0', () => { + expect(boostRelevance(0.5, 0.3)).toBeCloseTo(0.65); + }); + + it('does not exceed 1.0', () => { + expect(boostRelevance(0.99, 0.5)).toBeLessThanOrEqual(1.0); + }); + + it('returns 0 for zero relevance with zero boost', () => { + expect(boostRelevance(0, 0)).toBe(0); + }); + + it('applies default boost factor of 0.3', () => { + expect(boostRelevance(0.5)).toBeCloseTo(0.65); + }); +}); diff --git a/packages/palace/src/__tests__/dedup.test.ts b/packages/palace/src/__tests__/dedup.test.ts new file mode 100644 index 00000000..680cc322 --- /dev/null +++ b/packages/palace/src/__tests__/dedup.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { isContentDuplicate, isExactDuplicate, findClosestMatch } from '../dedup.js'; + +describe('isContentDuplicate', () => { + const baseEmbedding = [1, 0, 0]; + const similarEmbedding = [0.99, 0.1, 0]; // very similar to base + const differentEmbedding = [0, 1, 0]; // orthogonal + + it('detects near-duplicate above threshold', () => { + expect(isContentDuplicate(baseEmbedding, [similarEmbedding], 0.9)).toBe(true); + }); + + it('rejects non-duplicate below threshold', () => { + expect(isContentDuplicate(baseEmbedding, [differentEmbedding], 0.9)).toBe(false); + }); + + it('returns false for empty existing embeddings', () => { + expect(isContentDuplicate(baseEmbedding, [], 0.9)).toBe(false); + }); + + it('skips embeddings with mismatched dimensions', () => { + expect(isContentDuplicate([1, 0], [[1, 0, 0]], 0.9)).toBe(false); + }); + + it('uses default threshold of 0.90', () => { + expect(isContentDuplicate(baseEmbedding, [similarEmbedding])).toBe(true); + }); +}); + +describe('isExactDuplicate', () => { + it('detects exact match', () => { + expect(isExactDuplicate('Hello World', 'Hello World')).toBe(true); + }); + + it('is case-insensitive', () => { + expect(isExactDuplicate('Hello', 'hello')).toBe(true); + }); + + it('trims whitespace', () => { + expect(isExactDuplicate(' hello ', 'hello')).toBe(true); + }); + + it('rejects different content', () => { + expect(isExactDuplicate('Hello', 'World')).toBe(false); + }); +}); + +describe('findClosestMatch', () => { + it('finds the closest embedding', () => { + const result = findClosestMatch( + [1, 0, 0], + [ + [0, 1, 0], + [0.9, 0.1, 0], + [0, 0, 1], + ] + ); + expect(result).not.toBeNull(); + expect(result!.index).toBe(1); + expect(result!.score).toBeGreaterThan(0.9); + }); + + it('returns null for empty embeddings', () => { + expect(findClosestMatch([1, 0], [])).toBeNull(); + }); + + it('returns null when no embedding exceeds minScore', () => { + expect(findClosestMatch([1, 0, 0], [[0, 1, 0]], 0.99)).toBeNull(); + }); +}); diff --git a/packages/palace/src/__tests__/extraction.test.ts b/packages/palace/src/__tests__/extraction.test.ts new file mode 100644 index 00000000..a787dc7a --- /dev/null +++ b/packages/palace/src/__tests__/extraction.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest'; +import { + buildExtractionPrompt, + parseExtractionResponse, + regexFallbackExtraction, +} from '../extraction.js'; + +describe('buildExtractionPrompt', () => { + it('includes hall types in the prompt', () => { + const prompt = buildExtractionPrompt('some content', { + hallTypes: ['decisions', 'events', 'discoveries'], + }); + expect(prompt).toContain('decisions, events, discoveries'); + expect(prompt).toContain('some content'); + }); + + it('includes title when provided', () => { + const prompt = buildExtractionPrompt('content', { + title: 'My Note', + hallTypes: ['decisions'], + }); + expect(prompt).toContain('Title: My Note'); + }); + + it('includes context when provided', () => { + const prompt = buildExtractionPrompt('content', { + context: 'Work brain', + hallTypes: ['decisions'], + }); + expect(prompt).toContain('Context: Work brain'); + }); + + it('omits title/context when not provided', () => { + const prompt = buildExtractionPrompt('content', { + hallTypes: ['decisions'], + }); + expect(prompt).not.toContain('Title:'); + expect(prompt).not.toContain('Context:'); + }); +}); + +describe('parseExtractionResponse', () => { + it('parses valid JSON array', () => { + const input = JSON.stringify([ + { hall: 'decisions', content: 'Use TypeScript', roomSlug: 'tech', entities: ['TypeScript'] }, + ]); + const result = parseExtractionResponse(input); + expect(result).toHaveLength(1); + expect(result[0].hall).toBe('decisions'); + expect(result[0].content).toBe('Use TypeScript'); + expect(result[0].roomSlug).toBe('tech'); + expect(result[0].entities).toEqual(['TypeScript']); + }); + + it('handles JSON wrapped in code fences', () => { + const input = + '```json\n[{"hall":"events","content":"Released v2","roomSlug":"releases","entities":[]}]\n```'; + const result = parseExtractionResponse(input); + expect(result).toHaveLength(1); + expect(result[0].hall).toBe('events'); + }); + + it('returns empty array for malformed JSON', () => { + expect(parseExtractionResponse('not json at all')).toEqual([]); + }); + + it('returns empty array for empty string', () => { + expect(parseExtractionResponse('')).toEqual([]); + }); + + it('filters out items missing required fields', () => { + const input = JSON.stringify([{ hall: 'decisions', content: 'valid' }, { noHall: true }]); + const result = parseExtractionResponse(input); + expect(result).toHaveLength(1); + }); + + it('defaults roomSlug to general', () => { + const input = JSON.stringify([{ hall: 'events', content: 'something' }]); + const result = parseExtractionResponse(input); + expect(result[0].roomSlug).toBe('general'); + }); + + it('handles room_slug snake_case variant', () => { + const input = JSON.stringify([{ hall: 'events', content: 'x', room_slug: 'my-room' }]); + const result = parseExtractionResponse(input); + expect(result[0].roomSlug).toBe('my-room'); + }); +}); + +describe('regexFallbackExtraction', () => { + it('extracts Decision: lines', () => { + const result = regexFallbackExtraction('Decision: Use Cosmos DB for storage'); + expect(result).toHaveLength(1); + expect(result[0].hall).toBe('decisions'); + expect(result[0].content).toContain('Use Cosmos DB'); + }); + + it('extracts TODO: lines', () => { + const result = regexFallbackExtraction('TODO: Fix the auth bug'); + expect(result).toHaveLength(1); + expect(result[0].hall).toBe('decisions'); + }); + + it('extracts Found: lines as discoveries', () => { + const result = regexFallbackExtraction('Found: New API endpoint for search'); + expect(result).toHaveLength(1); + expect(result[0].hall).toBe('discoveries'); + }); + + it('extracts @mentions as entities', () => { + const result = regexFallbackExtraction('Decision: @alice proposed the new schema'); + expect(result[0].entities).toContain('alice'); + }); + + it('extracts #tags as entities', () => { + const result = regexFallbackExtraction('Found: #typescript has better types'); + expect(result[0].entities).toContain('typescript'); + }); + + it('returns empty array for plain text without patterns', () => { + expect(regexFallbackExtraction('Just some random text here')).toEqual([]); + }); + + it('handles multiple patterns in multi-line content', () => { + const content = `Decision: Adopt ESM +Found: Vitest is faster +TODO: Migrate tests`; + const result = regexFallbackExtraction(content); + expect(result).toHaveLength(3); + }); +}); diff --git a/packages/palace/src/__tests__/halls.test.ts b/packages/palace/src/__tests__/halls.test.ts new file mode 100644 index 00000000..dd07b0c4 --- /dev/null +++ b/packages/palace/src/__tests__/halls.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { HALL_PRESETS, getHallPreset, hallFromLabel, ALL_HALL_TYPES } from '../halls.js'; + +describe('HALL_PRESETS', () => { + it('notelett has 6 halls', () => { + expect(HALL_PRESETS.notelett.halls).toHaveLength(6); + expect(HALL_PRESETS.notelett.halls).toContain('insights'); + expect(HALL_PRESETS.notelett.halls).not.toContain('errors'); + }); + + it('mindlyst has 6 halls with patterns and emotions', () => { + expect(HALL_PRESETS.mindlyst.halls).toHaveLength(6); + expect(HALL_PRESETS.mindlyst.halls).toContain('patterns'); + expect(HALL_PRESETS.mindlyst.halls).toContain('emotions'); + }); + + it('coding has 6 halls with errors and advice', () => { + expect(HALL_PRESETS.coding.halls).toHaveLength(6); + expect(HALL_PRESETS.coding.halls).toContain('errors'); + expect(HALL_PRESETS.coding.halls).toContain('advice'); + }); +}); + +describe('getHallPreset', () => { + it('returns preset by name', () => { + expect(getHallPreset('notelett')?.name).toBe('NoteLett'); + }); + + it('returns undefined for unknown preset', () => { + expect(getHallPreset('nonexistent')).toBeUndefined(); + }); +}); + +describe('hallFromLabel', () => { + it('matches exact hall type', () => { + expect(hallFromLabel('decisions')).toBe('decisions'); + }); + + it('is case-insensitive', () => { + expect(hallFromLabel('DECISIONS')).toBe('decisions'); + }); + + it('matches singular form via synonym', () => { + expect(hallFromLabel('decision')).toBe('decisions'); + }); + + it('maps synonyms to halls', () => { + expect(hallFromLabel('bug')).toBe('errors'); + expect(hallFromLabel('feeling')).toBe('emotions'); + expect(hallFromLabel('tip')).toBe('advice'); + expect(hallFromLabel('trend')).toBe('patterns'); + expect(hallFromLabel('fact')).toBe('discoveries'); + }); + + it('respects allowedHalls filter', () => { + // 'errors' is not in notelett preset + expect(hallFromLabel('bug', HALL_PRESETS.notelett.halls)).toBeUndefined(); + // 'errors' is in coding preset + expect(hallFromLabel('bug', HALL_PRESETS.coding.halls)).toBe('errors'); + }); + + it('returns undefined for unrecognized label', () => { + expect(hallFromLabel('xyzzy')).toBeUndefined(); + }); + + it('trims whitespace', () => { + expect(hallFromLabel(' events ')).toBe('events'); + }); +}); + +describe('ALL_HALL_TYPES', () => { + it('has 9 total hall types', () => { + expect(ALL_HALL_TYPES).toHaveLength(9); + }); +}); diff --git a/packages/palace/src/__tests__/kg.test.ts b/packages/palace/src/__tests__/kg.test.ts new file mode 100644 index 00000000..40590f1d --- /dev/null +++ b/packages/palace/src/__tests__/kg.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { findContradictions, mergeTriples, isTripleCurrent } from '../kg.js'; +import type { TripleInput } from '../kg.js'; + +const now = new Date('2025-06-01T00:00:00Z'); + +describe('isTripleCurrent', () => { + it('returns true for triple with no validTo', () => { + expect(isTripleCurrent({ validTo: undefined }, now)).toBe(true); + }); + + it('returns true for triple with future validTo', () => { + expect(isTripleCurrent({ validTo: '2026-01-01T00:00:00Z' }, now)).toBe(true); + }); + + it('returns false for triple with past validTo', () => { + expect(isTripleCurrent({ validTo: '2024-01-01T00:00:00Z' }, now)).toBe(false); + }); +}); + +describe('findContradictions', () => { + it('detects contradiction (same subject+predicate, different object)', () => { + const existing: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'PostgreSQL', validFrom: '2025-01-01' }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, + ]; + const result = findContradictions(existing, incoming, now); + expect(result).toHaveLength(1); + expect(result[0].existing.object).toBe('PostgreSQL'); + expect(result[0].incoming.object).toBe('Cosmos DB'); + }); + + it('does not flag same triple as contradiction', () => { + const existing: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-01-01' }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, + ]; + expect(findContradictions(existing, incoming, now)).toHaveLength(0); + }); + + it('ignores expired triples', () => { + const existing: TripleInput[] = [ + { + subject: 'App', + predicate: 'uses', + object: 'PostgreSQL', + validFrom: '2024-01-01', + validTo: '2024-12-31', + }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, + ]; + expect(findContradictions(existing, incoming, now)).toHaveLength(0); + }); + + it('is case-insensitive', () => { + const existing: TripleInput[] = [ + { subject: 'app', predicate: 'Uses', object: 'PostgreSQL', validFrom: '2025-01-01' }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, + ]; + expect(findContradictions(existing, incoming, now)).toHaveLength(1); + }); +}); + +describe('mergeTriples', () => { + it('adds non-conflicting triples', () => { + const existing: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'TypeScript', validFrom: '2025-01-01' }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'runs-on', object: 'Node.js', validFrom: '2025-06-01' }, + ]; + const result = mergeTriples(existing, incoming, now); + expect(result.added).toHaveLength(1); + expect(result.skipped).toHaveLength(0); + expect(result.invalidated).toHaveLength(0); + expect(result.merged).toHaveLength(2); + }); + + it('skips duplicate triples', () => { + const existing: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'TypeScript', validFrom: '2025-01-01' }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'TypeScript', validFrom: '2025-06-01' }, + ]; + const result = mergeTriples(existing, incoming, now); + expect(result.skipped).toHaveLength(1); + expect(result.added).toHaveLength(0); + }); + + it('invalidates contradicted triples', () => { + const existing: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'PostgreSQL', validFrom: '2025-01-01' }, + ]; + const incoming: TripleInput[] = [ + { subject: 'App', predicate: 'uses', object: 'Cosmos DB', validFrom: '2025-06-01' }, + ]; + const result = mergeTriples(existing, incoming, now); + expect(result.invalidated).toHaveLength(1); + expect(result.added).toHaveLength(1); + // The invalidated triple should have validTo set + const invalidated = result.merged.find(t => t.object === 'PostgreSQL'); + expect(invalidated?.validTo).toBeDefined(); + }); +}); diff --git a/packages/palace/src/__tests__/wakeup.test.ts b/packages/palace/src/__tests__/wakeup.test.ts new file mode 100644 index 00000000..a4e58b4d --- /dev/null +++ b/packages/palace/src/__tests__/wakeup.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { + buildWakeUpLayers, + truncateToTokenBudget, + estimateTokens, + WAKEUP_PRESETS, +} from '../wakeup.js'; + +describe('estimateTokens', () => { + it('estimates ~4 chars per token', () => { + expect(estimateTokens('abcd')).toBe(1); + expect(estimateTokens('abcdefgh')).toBe(2); + }); + + it('returns 0 for empty string', () => { + expect(estimateTokens('')).toBe(0); + }); +}); + +describe('truncateToTokenBudget', () => { + it('returns unchanged text if within budget', () => { + expect(truncateToTokenBudget('short', 100)).toBe('short'); + }); + + it('truncates long text with ellipsis', () => { + const longText = 'a'.repeat(1000); + const result = truncateToTokenBudget(longText, 10); // 40 chars max + expect(result.length).toBeLessThanOrEqual(43); // 40 + '...' + expect(result.endsWith('...')).toBe(true); + }); + + it('returns empty string for empty input', () => { + expect(truncateToTokenBudget('', 100)).toBe(''); + }); +}); + +describe('buildWakeUpLayers', () => { + const config = WAKEUP_PRESETS.notelett; // 600 total, 50/150/400 + + it('assembles all three layers', () => { + const result = buildWakeUpLayers( + 'Project: NoteLett', + 'Key fact: Uses Cosmos DB', + 'Related: User asked about search', + config + ); + expect(result.text).toContain('[Identity]'); + expect(result.text).toContain('[Critical Facts]'); + expect(result.text).toContain('[Relevant Memories]'); + expect(result.layers).toHaveLength(3); + }); + + it('handles empty L0', () => { + const result = buildWakeUpLayers('', 'facts', 'memories', config); + expect(result.layers.find(l => l.label === 'L0:identity')).toBeUndefined(); + expect(result.layers).toHaveLength(2); + }); + + it('handles all empty layers gracefully', () => { + const result = buildWakeUpLayers('', '', '', config); + expect(result.text).toBe(''); + expect(result.layers).toHaveLength(0); + expect(result.truncated).toBe(false); + }); + + it('marks truncated when content exceeds budget', () => { + const longL2 = 'x '.repeat(5000); + const result = buildWakeUpLayers('id', 'fact', longL2, config); + expect(result.truncated).toBe(true); + }); + + it('respects total budget', () => { + const result = buildWakeUpLayers( + 'identity context here', + 'critical facts here', + 'semantic memories here', + config + ); + const tokens = estimateTokens(result.text); + expect(tokens).toBeLessThanOrEqual(config.totalBudget + 10); // small margin for headers + }); +}); + +describe('WAKEUP_PRESETS', () => { + it('has notelett preset with 600 budget', () => { + expect(WAKEUP_PRESETS.notelett.totalBudget).toBe(600); + }); + + it('has mindlyst preset with 800 budget', () => { + expect(WAKEUP_PRESETS.mindlyst.totalBudget).toBe(800); + }); + + it('has coding preset with 800 budget', () => { + expect(WAKEUP_PRESETS.coding.totalBudget).toBe(800); + }); +}); diff --git a/packages/palace/src/config.ts b/packages/palace/src/config.ts new file mode 100644 index 00000000..569c9b5e --- /dev/null +++ b/packages/palace/src/config.ts @@ -0,0 +1,36 @@ +/** + * Palace configuration schema — Zod-based env var validation. + * + * Products extend their backend config with these palace-specific vars. + */ + +import { z } from 'zod'; + +export const palaceConfigSchema = z.object({ + PALACE_ENABLED: z + .enum(['true', 'false']) + .default('true') + .transform(v => v === 'true'), + + PALACE_WAKE_UP_BUDGET: z.coerce.number().default(800), + + PALACE_DEDUP_THRESHOLD: z.coerce.number().min(0).max(1).default(0.9), + + PALACE_RELEVANCE_HALF_LIFE_DAYS: z.coerce.number().min(1).default(30), + + PALACE_MAX_MEMORIES_PER_SEARCH: z.coerce.number().min(1).default(20), + + PALACE_EXTRACTION_MAX_CHARS: z.coerce.number().min(100).default(6000), +}); + +export type PalaceConfig = z.infer; + +/** + * Parse palace config from environment. + * Products typically merge this with their own config schema. + */ +export function parsePalaceConfig( + env: Record = process.env as Record +): PalaceConfig { + return palaceConfigSchema.parse(env); +} diff --git a/packages/palace/src/cosine.ts b/packages/palace/src/cosine.ts new file mode 100644 index 00000000..c6b5ae2f --- /dev/null +++ b/packages/palace/src/cosine.ts @@ -0,0 +1,70 @@ +/** + * Vector similarity utilities for semantic search and deduplication. + */ + +/** + * Compute cosine similarity between two vectors. + * Returns a value between -1 and 1 (1 = identical direction). + * Returns 0 if either vector is zero-length or dimensions don't match. + */ +export function cosineSimilarity(a: number[], b: number[]): number { + if (a.length !== b.length || a.length === 0) return 0; + + let dotProduct = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + + const denominator = Math.sqrt(normA) * Math.sqrt(normB); + if (denominator === 0) return 0; + + return dotProduct / denominator; +} + +/** + * Normalize a vector to unit length (magnitude = 1). + * Returns a zero vector if input is zero-length. + */ +export function normalizeVector(v: number[]): number[] { + const magnitude = Math.sqrt(v.reduce((sum, val) => sum + val * val, 0)); + if (magnitude === 0) return v.map(() => 0); + return v.map(val => val / magnitude); +} + +/** + * Find the top-K most similar items to a query vector. + * + * @param query - The query embedding vector + * @param items - Array of items to search + * @param getEmbedding - Function to extract embedding from an item (returns undefined if missing) + * @param k - Maximum number of results to return + * @param minScore - Minimum cosine similarity score (default: 0) + * @returns Sorted array of { item, score } pairs, highest score first + */ +export function topKByCosine( + query: number[], + items: T[], + getEmbedding: (item: T) => number[] | undefined, + k: number, + minScore = 0 +): Array<{ item: T; score: number }> { + const scored: Array<{ item: T; score: number }> = []; + + for (const item of items) { + const embedding = getEmbedding(item); + if (!embedding || embedding.length === 0) continue; + + const score = cosineSimilarity(query, embedding); + if (score >= minScore) { + scored.push({ item, score }); + } + } + + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, k); +} diff --git a/packages/palace/src/decay.ts b/packages/palace/src/decay.ts new file mode 100644 index 00000000..122e85f1 --- /dev/null +++ b/packages/palace/src/decay.ts @@ -0,0 +1,52 @@ +/** + * Relevance decay for palace memories. + * + * Uses exponential half-life decay: relevance halves every N days. + * Memories that are accessed/referenced get their relevance boosted. + */ + +const MS_PER_DAY = 86_400_000; + +/** + * Compute decayed relevance using exponential half-life. + * + * @param originalRelevance - Initial relevance score (0-1) + * @param createdAt - ISO date string or Date when the memory was created/last accessed + * @param halfLifeDays - Number of days for relevance to halve (default: 30) + * @param asOf - Reference time for decay calculation (default: now) + * @returns Decayed relevance score (0-1) + */ +export function computeDecayedRelevance( + originalRelevance: number, + createdAt: string | Date, + halfLifeDays = 30, + asOf: Date = new Date() +): number { + if (halfLifeDays <= 0) return originalRelevance; + if (originalRelevance <= 0) return 0; + + const created = typeof createdAt === 'string' ? new Date(createdAt) : createdAt; + const elapsedMs = asOf.getTime() - created.getTime(); + + if (elapsedMs <= 0) return Math.min(originalRelevance, 1); + + const elapsedDays = elapsedMs / MS_PER_DAY; + const decayFactor = Math.pow(0.5, elapsedDays / halfLifeDays); + + return Math.max(0, Math.min(1, originalRelevance * decayFactor)); +} + +/** + * Compute a boosted relevance for a memory that was accessed/referenced. + * + * Boost formula: new = old + (1 - old) * boostFactor + * This asymptotically approaches 1.0 without exceeding it. + * + * @param currentRelevance - Current relevance score (0-1) + * @param boostFactor - How much of the remaining gap to close (default: 0.3) + * @returns Boosted relevance score (0-1) + */ +export function boostRelevance(currentRelevance: number, boostFactor = 0.3): number { + const boosted = currentRelevance + (1 - currentRelevance) * boostFactor; + return Math.min(1, Math.max(0, boosted)); +} diff --git a/packages/palace/src/dedup.ts b/packages/palace/src/dedup.ts new file mode 100644 index 00000000..9fe604e6 --- /dev/null +++ b/packages/palace/src/dedup.ts @@ -0,0 +1,64 @@ +/** + * Deduplication utilities for palace memories. + * + * Detects near-duplicate content using cosine similarity over embeddings. + * Products handle the Cosmos/DB queries; this module operates on pure data. + */ + +import { cosineSimilarity } from './cosine.js'; + +/** + * Check if a candidate embedding is a near-duplicate of any existing embedding. + * + * @param candidate - Embedding of the new memory + * @param existingEmbeddings - Embeddings of existing memories in the same room/hall + * @param threshold - Cosine similarity threshold (default: 0.90) + * @returns true if any existing embedding exceeds the threshold + */ +export function isContentDuplicate( + candidate: number[], + existingEmbeddings: number[][], + threshold = 0.9 +): boolean { + for (const existing of existingEmbeddings) { + if (existing.length !== candidate.length) continue; + if (cosineSimilarity(candidate, existing) > threshold) { + return true; + } + } + return false; +} + +/** + * Check if two text strings are exact duplicates after normalization. + * Trims whitespace and lowercases before comparison. + */ +export function isExactDuplicate(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +/** + * Find the most similar embedding and return its index + score. + * Returns null if no embeddings exist or none exceed minScore. + */ +export function findClosestMatch( + candidate: number[], + existingEmbeddings: number[][], + minScore = 0 +): { index: number; score: number } | null { + let bestIndex = -1; + let bestScore = minScore; + + for (let i = 0; i < existingEmbeddings.length; i++) { + const existing = existingEmbeddings[i]; + if (existing.length !== candidate.length) continue; + + const score = cosineSimilarity(candidate, existing); + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + + return bestIndex >= 0 ? { index: bestIndex, score: bestScore } : null; +} diff --git a/packages/palace/src/extraction.ts b/packages/palace/src/extraction.ts new file mode 100644 index 00000000..bd3c3049 --- /dev/null +++ b/packages/palace/src/extraction.ts @@ -0,0 +1,154 @@ +/** + * Memory extraction utilities — prompt building, response parsing, regex fallback. + * + * Products call their own LLM provider with the prompt from buildExtractionPrompt(), + * then pass the response to parseExtractionResponse(). + * If LLM is unavailable, regexFallbackExtraction() provides basic extraction. + */ + +import type { ExtractedMemory } from './types.js'; + +export interface ExtractionContext { + title?: string; + context?: string; + hallTypes: readonly string[]; +} + +/** + * Build a structured extraction prompt for an LLM. + * + * @param content - The text content to extract memories from + * @param ctx - Context including title, additional context, and allowed hall types + * @returns A system/user prompt string ready for LLM chat() + */ +export function buildExtractionPrompt(content: string, ctx: ExtractionContext): string { + const hallList = ctx.hallTypes.join(', '); + const titleLine = ctx.title ? `\nTitle: ${ctx.title}` : ''; + const contextLine = ctx.context ? `\nContext: ${ctx.context}` : ''; + + return `Extract structured memories from the following content. + +For each distinct memory, return a JSON array where each element has: +- "hall": one of [${hallList}] +- "content": the memory summarized in 1-2 sentences +- "roomSlug": a short kebab-case topic slug (e.g. "auth-migration", "api-design") +- "entities": array of named entities mentioned (people, projects, technologies, places) + +Rules: +- Only extract genuinely important or referenceable facts, decisions, or events +- Skip trivial or obvious statements +- Each memory should be self-contained (understandable without the original context) +- Prefer specific details over vague summaries +- Return valid JSON only — no markdown fences, no explanation${titleLine}${contextLine} + +Content: +${content}`; +} + +/** + * Parse an LLM extraction response into ExtractedMemory[]. + * + * Handles: + * - Clean JSON arrays + * - JSON wrapped in markdown code fences + * - Malformed JSON (returns empty array) + */ +export function parseExtractionResponse(llmOutput: string): ExtractedMemory[] { + if (!llmOutput || llmOutput.trim().length === 0) return []; + + let cleaned = llmOutput.trim(); + + // Strip markdown code fences if present + if (cleaned.startsWith('```')) { + cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, ''); + } + + try { + const parsed = JSON.parse(cleaned); + + if (!Array.isArray(parsed)) return []; + + return parsed + .filter( + (item: unknown): item is Record => + typeof item === 'object' && item !== null && 'hall' in item && 'content' in item + ) + .map(item => ({ + hall: String(item.hall || ''), + content: String(item.content || ''), + roomSlug: String(item.roomSlug || item.room_slug || 'general'), + entities: Array.isArray(item.entities) ? item.entities.map(String) : [], + })); + } catch { + return []; + } +} + +/** + * Regex-based fallback extraction when LLM is unavailable. + * + * Scans for common patterns: + * - "Decision:" / "Decided:" → decisions + * - "TODO:" / "Action:" → decisions + * - "Found:" / "Discovered:" / "Learned:" → discoveries + * - "Prefer:" / "Always:" / "Never:" → preferences + * - "Event:" / "Happened:" / date patterns → events + * - "Tip:" / "Note:" / "Remember:" → advice + * + * @param content - Raw text content + * @returns Array of extracted memories (best-effort) + */ +export function regexFallbackExtraction(content: string): ExtractedMemory[] { + const memories: ExtractedMemory[] = []; + const lines = content.split('\n'); + + const patterns: Array<{ regex: RegExp; hall: string }> = [ + { regex: /^(?:decision|decided|resolve[ds]?):\s*(.+)/i, hall: 'decisions' }, + { regex: /^(?:todo|action|task):\s*(.+)/i, hall: 'decisions' }, + { regex: /^(?:found|discovered|learned|til):\s*(.+)/i, hall: 'discoveries' }, + { regex: /^(?:prefer|always|never):\s*(.+)/i, hall: 'preferences' }, + { regex: /^(?:event|happened|occurred):\s*(.+)/i, hall: 'events' }, + { regex: /^(?:tip|note|remember|important):\s*(.+)/i, hall: 'advice' }, + { regex: /^(?:error|bug|issue|broken):\s*(.+)/i, hall: 'errors' }, + { regex: /^(?:pattern|recurring|trend):\s*(.+)/i, hall: 'patterns' }, + { regex: /^(?:feeling|mood|emotion):\s*(.+)/i, hall: 'emotions' }, + { regex: /^(?:insight|observation|noticed):\s*(.+)/i, hall: 'insights' }, + ]; + + for (const line of lines) { + const trimmed = line.replace(/^[\s\-*>#]+/, '').trim(); + if (!trimmed) continue; + + for (const { regex, hall } of patterns) { + const match = trimmed.match(regex); + if (match && match[1]) { + memories.push({ + hall, + content: match[1].trim(), + roomSlug: 'general', + entities: extractEntities(match[1]), + }); + break; + } + } + } + + return memories; +} + +/** + * Extract simple entities from text (mentions, tags, capitalized phrases). + */ +function extractEntities(text: string): string[] { + const entities = new Set(); + + // @mentions + const mentions = text.match(/@(\w+)/g); + if (mentions) mentions.forEach(m => entities.add(m.slice(1))); + + // #tags + const tags = text.match(/#(\w+)/g); + if (tags) tags.forEach(t => entities.add(t.slice(1))); + + return Array.from(entities); +} diff --git a/packages/palace/src/halls.ts b/packages/palace/src/halls.ts new file mode 100644 index 00000000..3e4220f6 --- /dev/null +++ b/packages/palace/src/halls.ts @@ -0,0 +1,103 @@ +/** + * Hall types and presets for different products. + * + * Each product picks a preset (or defines custom halls). + * Halls categorize memories by type — decisions, events, discoveries, etc. + */ + +export const ALL_HALL_TYPES = [ + 'decisions', + 'events', + 'discoveries', + 'preferences', + 'advice', + 'insights', + 'patterns', + 'emotions', + 'errors', +] as const; + +export type HallType = (typeof ALL_HALL_TYPES)[number]; + +export interface HallPreset { + name: string; + halls: HallType[]; +} + +/** + * Product-specific hall presets. + * + * - notelett: insights instead of errors (note-taking domain) + * - mindlyst: patterns + emotions (multimodal/emotional domain) + * - coding: errors + advice (developer/agent domain, e.g. Claw-Cowork) + */ +export const HALL_PRESETS: Record = { + notelett: { + name: 'NoteLett', + halls: ['decisions', 'events', 'discoveries', 'preferences', 'advice', 'insights'], + }, + mindlyst: { + name: 'MindLyst', + halls: ['decisions', 'events', 'discoveries', 'preferences', 'patterns', 'emotions'], + }, + coding: { + name: 'Coding Agent', + halls: ['decisions', 'events', 'discoveries', 'preferences', 'advice', 'errors'], + }, +}; + +/** + * Get a hall preset by name. + * Returns undefined if the preset does not exist. + */ +export function getHallPreset(presetName: string): HallPreset | undefined { + return HALL_PRESETS[presetName]; +} + +/** + * Classify a label string to the closest hall type. + * Case-insensitive, tries exact match first, then substring. + * Returns undefined if no match. + */ +export function hallFromLabel(label: string, allowedHalls?: HallType[]): HallType | undefined { + const normalized = label.toLowerCase().trim(); + const candidates = allowedHalls ?? (ALL_HALL_TYPES as unknown as HallType[]); + + // Exact match + const exact = candidates.find(h => h === normalized); + if (exact) return exact; + + // Substring match (e.g. "decision" → "decisions") + const partial = candidates.find(h => h.startsWith(normalized) || normalized.startsWith(h)); + if (partial) return partial; + + // Common synonyms + const synonymMap: Record = { + decision: 'decisions', + event: 'events', + discovery: 'discoveries', + preference: 'preferences', + insight: 'insights', + pattern: 'patterns', + emotion: 'emotions', + error: 'errors', + fact: 'discoveries', + finding: 'discoveries', + todo: 'decisions', + task: 'decisions', + bug: 'errors', + fix: 'decisions', + feeling: 'emotions', + mood: 'emotions', + trend: 'patterns', + recurring: 'patterns', + tip: 'advice', + recommendation: 'advice', + suggestion: 'advice', + }; + + const synonym = synonymMap[normalized]; + if (synonym && candidates.includes(synonym)) return synonym; + + return undefined; +} diff --git a/packages/palace/src/index.ts b/packages/palace/src/index.ts new file mode 100644 index 00000000..4689d58b --- /dev/null +++ b/packages/palace/src/index.ts @@ -0,0 +1,48 @@ +// ── Types ────────────────────────────────────────────────────────── +export type { + BasePalaceWingDoc, + BasePalaceRoomDoc, + BasePalaceMemoryDoc, + BasePalaceTunnelDoc, + BasePalaceKGTripleDoc, + BasePalaceDiaryDoc, + ExtractedMemory, +} from './types.js'; + +// ── Halls ────────────────────────────────────────────────────────── +export { ALL_HALL_TYPES, HALL_PRESETS, getHallPreset, hallFromLabel } from './halls.js'; +export type { HallType, HallPreset } from './halls.js'; + +// ── Cosine Similarity ────────────────────────────────────────────── +export { cosineSimilarity, normalizeVector, topKByCosine } from './cosine.js'; + +// ── Deduplication ────────────────────────────────────────────────── +export { isContentDuplicate, isExactDuplicate, findClosestMatch } from './dedup.js'; + +// ── Relevance Decay ──────────────────────────────────────────────── +export { computeDecayedRelevance, boostRelevance } from './decay.js'; + +// ── Extraction ───────────────────────────────────────────────────── +export { + buildExtractionPrompt, + parseExtractionResponse, + regexFallbackExtraction, +} from './extraction.js'; +export type { ExtractionContext } from './extraction.js'; + +// ── Knowledge Graph ──────────────────────────────────────────────── +export { findContradictions, mergeTriples, isTripleCurrent } from './kg.js'; +export type { TripleInput } from './kg.js'; + +// ── Wake-Up Context ──────────────────────────────────────────────── +export { + buildWakeUpLayers, + truncateToTokenBudget, + estimateTokens, + WAKEUP_PRESETS, +} from './wakeup.js'; +export type { WakeUpConfig, WakeUpContext, WakeUpLayer } from './wakeup.js'; + +// ── Config ───────────────────────────────────────────────────────── +export { palaceConfigSchema, parsePalaceConfig } from './config.js'; +export type { PalaceConfig } from './config.js'; diff --git a/packages/palace/src/kg.ts b/packages/palace/src/kg.ts new file mode 100644 index 00000000..def00520 --- /dev/null +++ b/packages/palace/src/kg.ts @@ -0,0 +1,138 @@ +/** + * Knowledge graph helpers for palace triple management. + * + * Triples are (subject, predicate, object) with temporal validity. + * This module provides pure functions for contradiction detection, + * merging, and currency checking. + */ + +/** + * A lightweight triple for comparison (does not require full doc fields). + */ +export interface TripleInput { + subject: string; + predicate: string; + object: string; + validFrom: string; + validTo?: string; + confidence?: number; +} + +/** + * Find contradictions between existing triples and incoming ones. + * + * A contradiction exists when: + * - Same subject + predicate, different object + * - Both are currently valid (no validTo or validTo in the future) + * + * @returns Array of { existing, incoming } contradiction pairs + */ +export function findContradictions( + existing: TripleInput[], + incoming: TripleInput[], + asOf: Date = new Date() +): Array<{ existing: TripleInput; incoming: TripleInput }> { + const contradictions: Array<{ existing: TripleInput; incoming: TripleInput }> = []; + + for (const inc of incoming) { + for (const ext of existing) { + if ( + normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && + normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && + normalizeEntity(ext.object) !== normalizeEntity(inc.object) && + isTripleCurrent(ext, asOf) && + isTripleCurrent(inc, asOf) + ) { + contradictions.push({ existing: ext, incoming: inc }); + } + } + } + + return contradictions; +} + +/** + * Merge incoming triples into existing set. + * + * - If an incoming triple contradicts an existing one, the existing triple + * is invalidated (validTo set) and the incoming one is kept. + * - If an incoming triple is a duplicate (same S/P/O), it is skipped. + * - Otherwise, the incoming triple is added. + * + * @returns { merged, invalidated, added, skipped } counts + */ +export function mergeTriples( + existing: TripleInput[], + incoming: TripleInput[], + asOf: Date = new Date() +): { + merged: TripleInput[]; + invalidated: TripleInput[]; + added: TripleInput[]; + skipped: TripleInput[]; +} { + const invalidated: TripleInput[] = []; + const added: TripleInput[] = []; + const skipped: TripleInput[] = []; + + const merged = [...existing]; + + for (const inc of incoming) { + // Check for exact duplicate + const isDuplicate = merged.some( + ext => + normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && + normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && + normalizeEntity(ext.object) === normalizeEntity(inc.object) && + isTripleCurrent(ext, asOf) + ); + + if (isDuplicate) { + skipped.push(inc); + continue; + } + + // Check for contradiction + const contradictIdx = merged.findIndex( + ext => + normalizeEntity(ext.subject) === normalizeEntity(inc.subject) && + normalizeEntity(ext.predicate) === normalizeEntity(inc.predicate) && + normalizeEntity(ext.object) !== normalizeEntity(inc.object) && + isTripleCurrent(ext, asOf) + ); + + if (contradictIdx >= 0) { + // Invalidate the old triple + const old = merged[contradictIdx]; + merged[contradictIdx] = { ...old, validTo: asOf.toISOString() }; + invalidated.push(old); + } + + merged.push(inc); + added.push(inc); + } + + return { merged, invalidated, added, skipped }; +} + +/** + * Check if a triple is currently valid. + * + * @param triple - The triple to check + * @param asOf - Reference time (default: now) + * @returns true if the triple has no validTo or validTo is in the future + */ +export function isTripleCurrent( + triple: Pick, + asOf: Date = new Date() +): boolean { + if (!triple.validTo) return true; + return new Date(triple.validTo).getTime() > asOf.getTime(); +} + +/** + * Normalize an entity string for comparison (lowercase, trim, collapse whitespace). + */ +function normalizeEntity(s: string): string { + return s.toLowerCase().trim().replace(/\s+/g, ' '); +} diff --git a/packages/palace/src/types.ts b/packages/palace/src/types.ts new file mode 100644 index 00000000..d62f84b2 --- /dev/null +++ b/packages/palace/src/types.ts @@ -0,0 +1,105 @@ +/** + * Base palace document types. + * + * Products extend these with product-specific fields + * (e.g. sourceWorkspaceId for NoteLett, sourceBrainId for MindLyst). + */ + +// ── Wing (top-level grouping — workspace/brain/project) ──────────── + +export interface BasePalaceWingDoc { + id: string; + productId: string; + userId: string; + name: string; + description?: string; + memoryCount: number; + l1Cache?: string; + l1CacheUpdatedAt?: string; + createdAt: string; + updatedAt: string; +} + +// ── Room (topic within a wing) ───────────────────────────────────── + +export interface BasePalaceRoomDoc { + id: string; + productId: string; + userId: string; + wingId: string; + name: string; + description?: string; + memoryCount: number; + createdAt: string; + updatedAt: string; +} + +// ── Memory (core unit — one fact/decision/event/etc.) ────────────── + +export interface BasePalaceMemoryDoc { + id: string; + productId: string; + userId: string; + wingId: string; + roomId: string; + hall: string; + content: string; + relevance: number; + embedding?: number[]; + sourceId?: string; + createdAt: string; + updatedAt: string; +} + +// ── Tunnel (cross-room/cross-wing link) ──────────────────────────── + +export interface BasePalaceTunnelDoc { + id: string; + productId: string; + userId: string; + fromMemoryId: string; + fromWingId: string; + toMemoryId: string; + toWingId: string; + relationship: string; + strength: number; + createdAt: string; +} + +// ── Knowledge Graph Triple ───────────────────────────────────────── + +export interface BasePalaceKGTripleDoc { + id: string; + productId: string; + userId: string; + wingId: string; + subject: string; + predicate: string; + object: string; + confidence: number; + validFrom: string; + validTo?: string; + sourceMemoryId?: string; + createdAt: string; +} + +// ── Diary Entry ──────────────────────────────────────────────────── + +export interface BasePalaceDiaryDoc { + id: string; + productId: string; + userId: string; + roleId: string; + wingId?: string; + entry: string; + createdAt: string; +} + +// ── Extracted memory (output of extraction pipeline) ─────────────── + +export interface ExtractedMemory { + hall: string; + content: string; + roomSlug: string; + entities: string[]; +} diff --git a/packages/palace/src/wakeup.ts b/packages/palace/src/wakeup.ts new file mode 100644 index 00000000..22717b3f --- /dev/null +++ b/packages/palace/src/wakeup.ts @@ -0,0 +1,126 @@ +/** + * Wake-up context builder for palace-augmented sessions. + * + * Builds a layered context string (L0/L1/L2) within a token budget. + * Products provide the raw data; this module assembles and truncates. + */ + +export interface WakeUpLayer { + label: string; + content: string; + priority: number; +} + +export interface WakeUpConfig { + totalBudget: number; + l0Budget: number; + l1Budget: number; + l2Budget: number; +} + +export interface WakeUpContext { + text: string; + layers: { label: string; charCount: number }[]; + totalChars: number; + truncated: boolean; +} + +/** + * Approximate token count for a string. + * Uses the rough heuristic of ~4 characters per token. + */ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +/** + * Truncate text to fit within a token budget. + * + * @param text - Input text + * @param maxTokens - Maximum tokens allowed + * @returns Truncated text (with "..." appended if truncated) + */ +export function truncateToTokenBudget(text: string, maxTokens: number): string { + if (!text) return ''; + + const maxChars = maxTokens * 4; + if (text.length <= maxChars) return text; + + // Truncate at word boundary + const truncated = text.slice(0, maxChars); + const lastSpace = truncated.lastIndexOf(' '); + const cutPoint = lastSpace > maxChars * 0.8 ? lastSpace : maxChars; + + return truncated.slice(0, cutPoint) + '...'; +} + +/** + * Build a wake-up context from L0/L1/L2 layers within a total token budget. + * + * Layer priority: + * - L0 (identity/project context) — always included, smallest budget + * - L1 (critical facts from recent memories) — high priority + * - L2 (semantically relevant memories) — fills remaining budget + * + * @param l0 - Identity/project context string + * @param l1 - Critical facts string + * @param l2 - Semantically relevant memories string + * @param config - Token budget configuration + * @returns Assembled wake-up context with metadata + */ +export function buildWakeUpLayers( + l0: string, + l1: string, + l2: string, + config: WakeUpConfig +): WakeUpContext { + const layers: { label: string; charCount: number }[] = []; + const parts: string[] = []; + let truncated = false; + + // L0: identity (always included) + const l0Truncated = truncateToTokenBudget(l0, config.l0Budget); + if (l0Truncated) { + parts.push(`[Identity]\n${l0Truncated}`); + layers.push({ label: 'L0:identity', charCount: l0Truncated.length }); + if (l0Truncated.endsWith('...')) truncated = true; + } + + // L1: critical facts + const l1Truncated = truncateToTokenBudget(l1, config.l1Budget); + if (l1Truncated) { + parts.push(`[Critical Facts]\n${l1Truncated}`); + layers.push({ label: 'L1:facts', charCount: l1Truncated.length }); + if (l1Truncated.endsWith('...')) truncated = true; + } + + // L2: semantic context (gets remaining budget) + const usedTokens = estimateTokens(parts.join('\n\n')); + const remainingBudget = Math.max(0, config.totalBudget - usedTokens); + const l2Budget = Math.min(config.l2Budget, remainingBudget); + + const l2Truncated = truncateToTokenBudget(l2, l2Budget); + if (l2Truncated) { + parts.push(`[Relevant Memories]\n${l2Truncated}`); + layers.push({ label: 'L2:semantic', charCount: l2Truncated.length }); + if (l2Truncated.endsWith('...')) truncated = true; + } + + const text = parts.join('\n\n'); + + return { + text, + layers, + totalChars: text.length, + truncated, + }; +} + +/** + * Default wake-up configs for each product. + */ +export const WAKEUP_PRESETS: Record = { + notelett: { totalBudget: 600, l0Budget: 50, l1Budget: 150, l2Budget: 400 }, + mindlyst: { totalBudget: 800, l0Budget: 80, l1Budget: 200, l2Budget: 500 }, + coding: { totalBudget: 800, l0Budget: 80, l1Budget: 200, l2Budget: 500 }, +}; diff --git a/packages/palace/tsconfig.json b/packages/palace/tsconfig.json new file mode 100644 index 00000000..5edad813 --- /dev/null +++ b/packages/palace/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +}