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:
saravanakumardb1 2026-04-10 00:57:00 -07:00
parent 031e910607
commit d1c6cf47c8
19 changed files with 1564 additions and 0 deletions

View 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/"
}
}

View 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([]);
});
});

View 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);
});
});

View 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();
});
});

View 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);
});
});

View 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);
});
});

View 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();
});
});

View 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);
});
});

View 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);
}

View 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);
}

View 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));
}

View 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;
}

View 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);
}

View 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;
}

View 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
View 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, ' ');
}

View 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[];
}

View 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 },
};

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}