feat(palace): add @bytelyst/palace shared package — MemPalace primitives (91 tests)
New shared package: packages/palace/ (@bytelyst/palace) Modules: - types.ts — BasePalaceWingDoc, RoomDoc, MemoryDoc, TunnelDoc, KGTripleDoc, DiaryDoc - halls.ts — HallType union, HALL_PRESETS (notelett/mindlyst/coding), hallFromLabel() - cosine.ts — cosineSimilarity(), topKByCosine(), normalizeVector() - dedup.ts — isContentDuplicate(), isExactDuplicate(), findClosestMatch() - decay.ts — computeDecayedRelevance(), boostRelevance() - extraction.ts — buildExtractionPrompt(), parseExtractionResponse(), regexFallbackExtraction() - kg.ts — findContradictions(), mergeTriples(), isTripleCurrent() - wakeup.ts — buildWakeUpLayers(), truncateToTokenBudget(), WAKEUP_PRESETS - config.ts — palaceConfigSchema (Zod) 7 test files, 91 tests passing. Consumed by NoteLett, MindLyst, and future palace-enabled products.
This commit is contained in:
parent
031e910607
commit
d1c6cf47c8
31
packages/palace/package.json
Normal file
31
packages/palace/package.json
Normal file
@ -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/"
|
||||
}
|
||||
}
|
||||
86
packages/palace/src/__tests__/cosine.test.ts
Normal file
86
packages/palace/src/__tests__/cosine.test.ts
Normal file
@ -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([]);
|
||||
});
|
||||
});
|
||||
57
packages/palace/src/__tests__/decay.test.ts
Normal file
57
packages/palace/src/__tests__/decay.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
70
packages/palace/src/__tests__/dedup.test.ts
Normal file
70
packages/palace/src/__tests__/dedup.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
131
packages/palace/src/__tests__/extraction.test.ts
Normal file
131
packages/palace/src/__tests__/extraction.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
75
packages/palace/src/__tests__/halls.test.ts
Normal file
75
packages/palace/src/__tests__/halls.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
113
packages/palace/src/__tests__/kg.test.ts
Normal file
113
packages/palace/src/__tests__/kg.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
96
packages/palace/src/__tests__/wakeup.test.ts
Normal file
96
packages/palace/src/__tests__/wakeup.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
36
packages/palace/src/config.ts
Normal file
36
packages/palace/src/config.ts
Normal file
@ -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<typeof palaceConfigSchema>;
|
||||
|
||||
/**
|
||||
* Parse palace config from environment.
|
||||
* Products typically merge this with their own config schema.
|
||||
*/
|
||||
export function parsePalaceConfig(
|
||||
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>
|
||||
): PalaceConfig {
|
||||
return palaceConfigSchema.parse(env);
|
||||
}
|
||||
70
packages/palace/src/cosine.ts
Normal file
70
packages/palace/src/cosine.ts
Normal file
@ -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<T>(
|
||||
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);
|
||||
}
|
||||
52
packages/palace/src/decay.ts
Normal file
52
packages/palace/src/decay.ts
Normal file
@ -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));
|
||||
}
|
||||
64
packages/palace/src/dedup.ts
Normal file
64
packages/palace/src/dedup.ts
Normal file
@ -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;
|
||||
}
|
||||
154
packages/palace/src/extraction.ts
Normal file
154
packages/palace/src/extraction.ts
Normal file
@ -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<string, unknown> =>
|
||||
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<string>();
|
||||
|
||||
// @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);
|
||||
}
|
||||
103
packages/palace/src/halls.ts
Normal file
103
packages/palace/src/halls.ts
Normal file
@ -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<string, HallPreset> = {
|
||||
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<string, HallType> = {
|
||||
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;
|
||||
}
|
||||
48
packages/palace/src/index.ts
Normal file
48
packages/palace/src/index.ts
Normal file
@ -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';
|
||||
138
packages/palace/src/kg.ts
Normal file
138
packages/palace/src/kg.ts
Normal file
@ -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<TripleInput, 'validTo'>,
|
||||
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, ' ');
|
||||
}
|
||||
105
packages/palace/src/types.ts
Normal file
105
packages/palace/src/types.ts
Normal file
@ -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[];
|
||||
}
|
||||
126
packages/palace/src/wakeup.ts
Normal file
126
packages/palace/src/wakeup.ts
Normal file
@ -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<string, WakeUpConfig> = {
|
||||
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 },
|
||||
};
|
||||
9
packages/palace/tsconfig.json
Normal file
9
packages/palace/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user