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