learning_ai_notes/docs/MEMPALACE_INTEGRATION_ROADMAP.md
2026-04-10 01:27:31 -07:00

40 KiB
Raw Blame History

MemPalace Integration Roadmap — NoteLett

Goal: Add persistent, structured semantic memory to NoteLett so users build a personal knowledge palace — notes auto-organized into wings (workspaces), rooms (topics), and halls (memory types) — with cross-note knowledge graph, semantic search, and AI-assisted recall. All with strict user isolation at every layer.

Ref: MemPalace — highest-scoring AI memory system (96.6% LongMemEval). Adapted for a multi-user Fastify/Cosmos backend.


Design Decisions

# Decision Choice Rationale
0 Shared package New @bytelyst/palace in learning_ai_common_plat/packages/palace/ Core palace types, cosine similarity, dedup logic, relevance decay, KG helpers, extraction prompts, and wake-up builder are product-agnostic — shared across NoteLett, MindLyst, and future products
1 Storage Cosmos DB only (via @bytelyst/datastore) NoteLett is already Cosmos-native; no SQLite or ChromaDB — keeps infra simple, user isolation inherits from Cosmos partition keys
2 Vector search Cosmos DB vector index (DiskANN) or in-memory cosine over stored embeddings Avoids introducing ChromaDB; Cosmos vector search is GA; falls back to cosineSimilarity() from existing embeddings.ts
3 Embedding model @bytelyst/llm embed() — text-embedding-3-small (1536-dim) Already configured in config.ts (LLM_EMBEDDING_MODEL), existing embedText() in lib/embeddings.ts
4 Extraction @bytelyst/llm chat() — gpt-4o-mini with structured output + regex fallback Same LLM infrastructure as Smart Actions; mock provider for tests
5 User isolation userId required on every document and every query filter, enforced at repository layer Same pattern as all existing NoteLett modules (notes, workspaces, etc.)
6 Palace scope Per-user palace, workspace = wing Each user has their own palace; their NoteLett workspaces map to wings
7 Hall types 6 halls: decisions, events, discoveries, preferences, advice, insights insights replaces errors (coding-focused); captures AI-generated observations from note analysis
8 Wake-up budget ~600 tokens (L0:50 + L1:150 + L2:400) Enough for note-writing context; smaller than Claw-Cowork's 800 since notes have narrower scope
9 MCP integration Extend existing MCP tools in backend/src/mcp/ NoteLett already has 8 MCP tools; add mempalace_* tools to the same system
10 Save triggers Note create/update, agent action completion, prompt execution, explicit store Event-driven based on NoteLett's existing lifecycle hooks
11 Field encryption Sensitive memory content encrypted via @bytelyst/field-encrypt Mirrors existing note body encryption pattern in notes/repository.ts
12 productId scoping Every palace document includes productId: "notelett" Standard ByteLyst ecosystem convention

@bytelyst/* Package Dependency Map

NoteLett's palace module leverages these existing shared packages:

Package Usage in Palace
@bytelyst/datastore All palace Cosmos CRUD via getCollection()
@bytelyst/field-encrypt Encrypt memory content at rest (AES-256-GCM)
@bytelyst/llm embed() for embeddings, chat() for extraction
@bytelyst/backend-config Extend baseBackendConfigSchema with palace env vars
@bytelyst/fastify-auth getUserId(req) for JWT-based user isolation
@bytelyst/fastify-core Route registration via createServiceApp()
@bytelyst/errors Typed errors: NotFoundError, BadRequestError
@bytelyst/cosmos registerContainers() for palace containers
@bytelyst/extraction createExtractionClient() for entity extraction via extraction-service
@bytelyst/testing Test helpers for buildTestApp()
@bytelyst/palace NEW — shared palace types, cosine, dedup, decay, KG helpers, extraction prompts, wake-up builder

Current State (What Exists)

Component File What It Does
Notes CRUD modules/notes/repository.ts Note storage with field encryption, userId filtering
Workspaces modules/workspaces/ Workspace CRUD — maps to MemPalace "wings"
Note Relationships modules/note-relationships/ Manual note linking — related to MemPalace "tunnels"
Embeddings lib/embeddings.ts embedText(), cosineSimilarity(), stripHtmlForEmbedding() — already exists
LLM lib/llm.ts @bytelyst/llm singleton — embed + chat capability
Field Encrypt lib/field-encrypt.ts @bytelyst/field-encrypt for sensitive data at rest
Request Context lib/request-context.ts getUserId(req), getRequestProductId(req) — JWT-extracted
MCP Tools mcp/note-tools.ts 8 MCP tools with requireUserId() + requireProductScope() guards
Smart Actions modules/note-prompts/ LLM-powered transforms — extraction patterns reusable for memory mining

Key Gap

Notes exist in isolation. No cross-note semantic memory, no knowledge graph connecting entities, no AI-assisted recall of past decisions. Users can search by text but not by meaning. Relationships are manual, not auto-discovered.


Architecture

┌──────────────────────────────────────────────────────────┐
│                    NoteLett Backend                        │
│                                                          │
│  ┌─────────────┐  ┌──────────────┐  ┌────────────────┐  │
│  │ Notes Module │  │ Palace Module│  │  MCP Tools     │  │
│  │ (existing)   │  │ (NEW)        │  │  (extended)    │  │
│  └──────┬───────┘  └──────┬───────┘  └───────┬────────┘  │
│         │                 │                   │           │
│  ┌──────┴─────────────────┴───────────────────┴────────┐ │
│  │              @bytelyst/datastore                      │ │
│  │   getCollection() — userId in every filter            │ │
│  └──────────────────────┬───────────────────────────────┘ │
│                         │                                  │
│  ┌──────────────────────┴───────────────────────────────┐ │
│  │              Cosmos DB (partition: /userId)            │ │
│  │                                                       │ │
│  │  ┌────────────┐ ┌───────────┐ ┌──────────────────┐   │ │
│  │  │palace_wings│ │palace_rooms│ │palace_memories    │   │ │
│  │  │/userId     │ │/userId    │ │/userId (+ vector) │   │ │
│  │  └────────────┘ └───────────┘ └──────────────────┘   │ │
│  │  ┌────────────┐ ┌───────────┐ ┌──────────────────┐   │ │
│  │  │palace_kg   │ │palace_    │ │palace_tunnels     │   │ │
│  │  │/userId     │ │diaries    │ │/userId            │   │ │
│  │  │            │ │/userId    │ │                    │   │ │
│  │  └────────────┘ └───────────┘ └──────────────────┘   │ │
│  └───────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘

User Isolation Model

Every palace operation is scoped to the authenticated user. Isolation is enforced at 3 layers:

  1. JWT Auth LayergetUserId(req) extracts userId from the verified JWT; no user input accepted for identity
  2. Repository Layer — Every query includes userId in its filter. No query can omit it.
  3. Cosmos Partition Layer — All palace containers use /userId as partition key. Cross-partition queries are physically impossible without the partition key.
// EVERY repository function signature includes userId — no exceptions:
export async function searchMemories(userId: string, productId: string, query: SearchQuery): Promise<PalaceMemoryDoc[]> {
  const filter: FilterMap = { userId, productId };
  // ... userId is ALWAYS in the filter
}

// Route handlers extract userId from JWT — never from request body:
fastify.get('/api/palace/search', async (req, reply) => {
  const userId = getUserId(req);           // from JWT
  const productId = getRequestProductId(req); // from JWT
  const query = SearchQuerySchema.parse(req.query);
  const results = await searchMemories(userId, productId, query);
  // ...
});

Workspace-to-Wing Mapping

NoteLett workspaces map 1:1 to palace wings:

NoteLett Concept Palace Concept Relationship
Workspace Wing wing.sourceWorkspaceId = workspace.id
Note tags Rooms Auto-created from note tags or AI-suggested topics
Note relationships Tunnels Existing links become tunnel seeds
Note body Memory content Extracted decisions, facts, insights from notes
Smart Action output Memory content Agent-generated memories from prompt executions

Phase N0 — Shared Palace Package (@bytelyst/palace)

Goal: Create a reusable shared package in learning_ai_common_plat/packages/palace/ with product-agnostic palace primitives.

Estimated: 3-4 days | New files: 8 | Tests: ~25

This phase is done in the learning_ai_common_plat repo, not in NoteLett.

N0.1 Package Scaffold

New package: packages/palace/ (@bytelyst/palace)

packages/palace/
├── src/
│   ├── index.ts            # Public exports
│   ├── types.ts            # PalaceWingDoc, PalaceRoomDoc, PalaceMemoryDoc, PalaceTunnelDoc, PalaceKGTripleDoc, PalaceDiaryDoc
│   ├── halls.ts            # HallType union, HALL_PRESETS (notelett, mindlyst, coding), hallFromLabel()
│   ├── cosine.ts           # cosineSimilarity(), topKByCosine(), normalizeVector()
│   ├── dedup.ts            # isNearDuplicate() — exact match + cosine threshold logic
│   ├── decay.ts            # decayRelevance(), computeDecayedRelevance() — exponential half-life
│   ├── extraction.ts       # buildExtractionPrompt(), parseExtractionResponse(), regexFallbackExtraction()
│   ├── kg.ts               # KG helper types, contradictionCheck(), mergeTriples()
│   ├── wakeup.ts           # WakeUpContext type, buildWakeUpLayers(), truncateToTokenBudget()
│   ├── config.ts           # palaceConfigSchema (Zod) — PALACE_ENABLED, budgets, thresholds
│   └── __tests__/
│       ├── cosine.test.ts
│       ├── dedup.test.ts
│       ├── decay.test.ts
│       ├── extraction.test.ts
│       ├── kg.test.ts
│       └── wakeup.test.ts
├── package.json            # @bytelyst/palace, peerDeps: @bytelyst/llm, zod
└── tsconfig.json

N0.2 Shared Types (types.ts)

Product-agnostic base types — products extend with their own fields:

import type { BaseDocument } from '@bytelyst/datastore';

// Base types — every product extends these
export interface BasePalaceWingDoc extends BaseDocument {
  userId: string;
  productId: string;
  name: string;
  memoryCount: number;
  l1Cache?: string;
  l1CacheUpdatedAt?: string;
  createdAt: string;
  updatedAt: string;
}

export interface BasePalaceMemoryDoc extends BaseDocument {
  userId: string;
  productId: string;
  wingId: string;
  roomId: string;
  hall: string;
  content: string;
  relevance: number;
  embedding?: number[];
  createdAt: string;
  updatedAt: string;
}

// ... similar for Room, Tunnel, KGTriple, Diary

N0.3 Cosine Similarity (cosine.ts)

Extracted from NoteLett's embeddings.ts — shared by all products:

export function cosineSimilarity(a: number[], b: number[]): number;
export function topKByCosine<T>(query: number[], items: T[], getEmbedding: (t: T) => number[] | undefined, k: number): Array<{ item: T; score: number }>;
export function normalizeVector(v: number[]): number[];

N0.4 Dedup, Decay, Extraction, KG, Wake-Up

All product-agnostic algorithms extracted into pure functions:

  • dedup.tsisContentDuplicate(content, candidates, threshold) (exact + cosine)
  • decay.tscomputeDecayedRelevance(originalRelevance, createdAt, halfLifeDays) (exponential)
  • extraction.tsbuildExtractionPrompt(content, context, hallTypes), parseExtractionResponse(llmOutput), regexFallbackExtraction(content)
  • kg.tsfindContradictions(triples), mergeTriples(existing, incoming), isTripleCurrent(triple, asOf?)
  • wakeup.tsbuildWakeUpLayers(l0, l1, l2, budget), truncateToTokenBudget(text, maxTokens)
  • config.tspalaceConfigSchema (Zod) extending baseBackendConfigSchema with PALACE_ENABLED, PALACE_WAKE_UP_BUDGET, PALACE_DEDUP_THRESHOLD, PALACE_RELEVANCE_HALF_LIFE_DAYS

N0.5 Tests

  • d1c6cf4 Cosine similarity correctness (identical=1.0, orthogonal=0.0)
  • d1c6cf4 topKByCosine returns sorted results, handles missing embeddings
  • d1c6cf4 Dedup detects exact matches and near-duplicates above threshold
  • d1c6cf4 Decay reduces relevance exponentially by half-life
  • d1c6cf4 Extraction prompt builder produces valid LLM prompts for all hall presets
  • d1c6cf4 Extraction response parser handles valid JSON, malformed JSON, empty
  • d1c6cf4 Regex fallback extracts decisions, events from markdown
  • d1c6cf4 KG contradiction detection finds conflicting triples
  • d1c6cf4 Wake-up layer builder respects token budget
  • d1c6cf4 Palace config schema validates all env vars with defaults

Phase N1 — Palace Core Module

Goal: New palace module with Cosmos-backed storage, user-isolated CRUD, and semantic search — consuming @bytelyst/palace for shared logic.

Estimated: 4-5 days | New files: 4 | Tests: ~30

N1.1 Palace Cosmos Containers

New containers in cosmos-init.ts:

// Add to CONTAINER_DEFS:
palace_wings:    { partitionKeyPath: '/userId' },
palace_rooms:    { partitionKeyPath: '/userId' },
palace_memories: { partitionKeyPath: '/userId' },
palace_tunnels:  { partitionKeyPath: '/userId' },
palace_kg:       { partitionKeyPath: '/userId' },
palace_diaries:  { partitionKeyPath: '/userId' },

N1.2 Palace Types (modules/palace/types.ts)

export interface PalaceWingDoc {
  id: string;
  productId: string;
  userId: string;
  name: string;                    // e.g. "Work Notes"
  sourceWorkspaceId: string;       // links to NoteLett workspace
  techStack?: string;              // auto-detected from note content
  memoryCount: number;             // denormalized for fast display
  createdAt: string;
  updatedAt: string;
}

export interface PalaceRoomDoc {
  id: string;
  productId: string;
  userId: string;
  wingId: string;
  name: string;                    // e.g. "auth-migration"
  description?: string;
  memoryCount: number;
  createdAt: string;
}

export const HALL_TYPES = ['decisions', 'events', 'discoveries', 'preferences', 'advice', 'insights'] as const;
export type HallType = (typeof HALL_TYPES)[number];

export interface PalaceMemoryDoc {
  id: string;
  productId: string;
  userId: string;
  wingId: string;
  roomId: string;
  hall: HallType;
  content: string;                 // the memory text
  sourceNoteId?: string;           // which note produced this memory
  sourceTaskId?: string;           // which agent action produced this
  relevance: number;               // 0.01.0, decays over time
  embedding?: number[];            // 1536-dim from text-embedding-3-small
  createdAt: string;
  updatedAt: string;
}

export interface PalaceTunnelDoc {
  id: string;
  productId: string;
  userId: string;
  roomA: string;                   // normalized: roomA < roomB
  roomB: string;
  reason: string;
  strength: number;                // 0.01.0
  createdAt: string;
}

export interface PalaceKGTripleDoc {
  id: string;
  productId: string;
  userId: string;
  wingId: string;
  subject: string;
  predicate: string;
  object: string;
  validFrom: string;
  validTo?: string;                // null = still current
  sourceTaskId?: string;
  confidence: number;
  createdAt: string;
}

export interface PalaceDiaryDoc {
  id: string;
  productId: string;
  userId: string;
  roleId: string;                  // e.g. "note-copilot"
  wingId?: string;
  entry: string;
  createdAt: string;
}

N1.3 Palace Repository (modules/palace/repository.ts)

Imports shared logic from @bytelyst/palace:

import { cosineSimilarity, topKByCosine } from '@bytelyst/palace';
import { isContentDuplicate } from '@bytelyst/palace';
import { computeDecayedRelevance } from '@bytelyst/palace';
import type { BasePalaceMemoryDoc } from '@bytelyst/palace';

All methods require userId + productId as first two params:

  • 44d8867 ensureWing(userId, productId, workspaceId, name) — upsert wing from workspace
  • 44d8867 getWing(userId, productId, wingId)PalaceWingDoc | null
  • 44d8867 listWings(userId, productId)PalaceWingDoc[]
  • 44d8867 deleteWing(userId, productId, wingId) — cascade delete rooms, memories, tunnels, KG
  • 44d8867 ensureRoom(userId, productId, wingId, name, description?) — upsert room
  • 44d8867 listRooms(userId, productId, wingId)PalaceRoomDoc[]
  • 44d8867 storeMemory(userId, productId, wingId, roomId, hall, content, sourceNoteId?, embedding?) — create + dedup
  • 44d8867 searchSemantic(userId, productId, query, embedding, wingId?, limit) → ranked memories
  • 44d8867 searchText(userId, productId, query, wingId?, limit) → text-matched memories
  • 44d8867 searchHybrid(userId, productId, query, embedding, wingId?, limit) → text candidates re-ranked by cosine
  • 44d8867 getWingSummary(userId, productId, wingId) → rooms + memory counts
  • 44d8867 isNearDuplicate(userId, productId, roomId, hall, content, embedding, threshold) → boolean
  • 44d8867 pruneOldMemories(userId, productId, wingId, olderThanDays, minRelevance) → count deleted
  • 44d8867 decayRelevance(userId, productId, halfLifeDays) → count updated
  • 44d8867 backfillEmbeddings(userId, productId) → count embedded
  • 44d8867 healthCheck(){ cosmos: boolean, llm: boolean }

User isolation enforcement:

// PATTERN: Every function starts with userId + productId in filter
export async function searchSemantic(
  userId: string,
  productId: string,
  query: string,
  embedding: number[],
  wingId?: string,
  limit = 10,
): Promise<PalaceMemoryDoc[]> {
  const filter: FilterMap = { userId, productId };
  if (wingId) filter.wingId = wingId;

  // Fetch candidates, compute cosine similarity client-side
  const candidates = await memoriesCollection().findMany({
    filter,
    sort: { updatedAt: -1 },
    limit: limit * 5, // over-fetch for re-ranking
  });

  return candidates
    .filter(m => m.embedding && m.embedding.length > 0)
    .map(m => ({ doc: m, score: cosineSimilarity(embedding, m.embedding!) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, limit)
    .map(r => r.doc);
}

N1.4 Memory Extraction (modules/palace/extractor.ts)

Leverages @bytelyst/palace extraction helpers + @bytelyst/llm for LLM calls:

import { buildExtractionPrompt, parseExtractionResponse, regexFallbackExtraction } from '@bytelyst/palace';
import { llm } from '../../lib/llm.js';
import { config } from '../../lib/config.js';

export interface ExtractedMemory {
  hall: HallType;
  content: string;
  roomSlug: string;
  entities: string[];
}

export async function extractMemories(
  noteBody: string,
  noteTitle: string,
  workspaceName: string,
): Promise<ExtractedMemory[]> {
  const provider = llm();

  // Use shared prompt builder from @bytelyst/palace
  const prompt = buildExtractionPrompt(noteBody.slice(0, 6000), {
    title: noteTitle,
    context: workspaceName,
    hallTypes: HALL_TYPES,
  });

  try {
    const result = await provider.chat({
      model: config.LLM_DEFAULT_MODEL,
      messages: [{ role: 'user', content: prompt }],
      temperature: 0.1,
    });
    // Use shared response parser from @bytelyst/palace
    return parseExtractionResponse(result.content);
  } catch {
    // Shared regex fallback from @bytelyst/palace
    return regexFallbackExtraction(noteBody);
  }
}

Regex fallback extracts:

  • Lines starting with "Decision:", "TODO:", "Note:" → appropriate halls
  • Markdown headers → room slugs
  • @mentions and #tags → entities

N1.5 Deduplication

Uses @bytelyst/palace dedup logic with Cosmos-backed candidate fetching:

import { isContentDuplicate } from '@bytelyst/palace';

export async function isNearDuplicate(
  userId: string, productId: string,
  roomId: string, hall: HallType,
  content: string, embedding: number[] | null,
  threshold = 0.90,
): Promise<boolean> {
  // 1. Exact match via Cosmos count (cheap)
  const exactCount = await memoriesCollection().count({ userId, productId, roomId, hall, content });
  if (exactCount > 0) return true;

  // 2. Semantic dedup via shared @bytelyst/palace helper
  if (embedding) {
    const candidates = await memoriesCollection().findMany({
      filter: { userId, productId, roomId, hall },
      sort: { createdAt: -1 },
      limit: 20,
    });
    // isContentDuplicate is pure — from @bytelyst/palace
    return isContentDuplicate(embedding, candidates.map(c => c.embedding).filter(Boolean) as number[][], threshold);
  }
  return false;
}

N1.6 Field Encryption for Memories

Sensitive memory content encrypted at rest, matching the existing pattern:

const ENCRYPT_CONTEXT = 'palace-memory';

async function encryptMemoryContent(doc: PalaceMemoryDoc): Promise<PalaceMemoryDoc> {
  const enc = getEncryptor();
  const ctx = { userId: doc.userId, context: ENCRYPT_CONTEXT };
  const encrypted = await enc.encrypt(doc.content, ctx);
  return { ...doc, content: encrypted as unknown as string };
}

N1.7 Tests

  • 632b5df Wing CRUD with user isolation (user A can't see user B's wings)
  • 632b5df Room CRUD scoped to wing + user
  • 632b5df Memory store with deduplication
  • 632b5df Semantic search returns relevant results ranked by cosine similarity
  • 632b5df Text search returns text-matched results
  • 632b5df Hybrid search combines text + semantic
  • 632b5df Near-duplicate detection (exact + cosine)
  • 632b5df Field encryption round-trip for memory content
  • 632b5df Prune removes old low-relevance memories only for the requesting user
  • 632b5df Relevance decay applies exponential reduction
  • 632b5df Wing deletion cascades to rooms, memories, tunnels, KG
  • 632b5df Cross-user isolation: user A's search never returns user B's memories
  • 632b5df productId scoping: wrong productId returns empty results

Phase N2 — Wake-Up Context

Goal: When a user starts working in a workspace, provide AI with relevant memory context from the palace.

Estimated: 3 days | New files: 1 | Tests: ~12

N2.1 Wake-Up Context Builder (modules/palace/wakeup.ts)

export interface WakeUpContext {
  l0Identity: string;    // ~50 tokens: workspace name, purpose
  l1Facts: string;       // ~150 tokens: recent decisions, preferences
  l2Context: string;     // ~400 tokens: task-relevant memories
  totalTokens: number;
}

export async function buildWakeUpContext(
  userId: string,
  productId: string,
  wingId: string,
  taskDescription?: string,
): Promise<WakeUpContext> { /* ... */ }

L0 — Identity (~50 tokens):

  • Workspace name and description (from wing → workspace)
  • Detected topic domains from room names

L1 — Critical Facts (~150 tokens):

  • Recent decisions and preferences from palace (last 30 days)
  • Current KG triples for this wing (if M3 built)
  • Cached summary, regenerated after each note save

L2 — Task Context (~400 tokens):

  • If taskDescription provided: semantic search against palace memories
  • Falls back to most recent memories across all halls

N2.2 Integration Points

Wake-up context is injected into:

  1. Smart Actionsnote-prompts/runner.ts prepends palace context to system prompt
  2. MCP tool callsmempalace_wake_up returns context for external agents
  3. Note Copilot — (future) AI writing assistant uses palace context

N2.3 L1 Cache Regeneration

After each note create/update that triggers memory extraction:

// Rebuild L1 facts cache for the wing
await regenerateCriticalFacts(userId, productId, wingId);
// 1. Pull recent memories from decisions + preferences + insights halls
// 2. Pull current KG triples (if N3 built, otherwise skip)
// 3. Produce compact ~150 token summary
// 4. Cache in wing doc (PalaceWingDoc.l1Cache field) for fast wake-up

N2.4 Tests

  • a5dbeac Wake-up context includes workspace identity (L0)
  • a5dbeac L1 facts reflect recent decisions and preferences
  • a5dbeac L2 context is semantically relevant to task description
  • a5dbeac L2 falls back to recent memories when no task description
  • a5dbeac Total tokens stay within ~600 budget
  • a5dbeac Wake-up for user A never includes user B's memories
  • a5dbeac Empty palace returns minimal context gracefully
  • a5dbeac L1 cache regeneration updates wing doc

Phase N3 — Knowledge Graph

Goal: Extract entity-relationship triples from notes, building a temporal knowledge graph per user.

Estimated: 4 days | New files: 1 | Tests: ~16

N3.1 KG Triple Extraction

During memory extraction (N1.4), also extract entity triples:

export interface ExtractedTriple {
  subject: string;    // "React Router"
  predicate: string;  // "replaced_by"
  object: string;     // "TanStack Router"
  confidence: number;
}

// LLM prompt addition:
// "Also extract entity relationships as (subject, predicate, object) triples."

N3.2 KG Repository Extensions

Added to modules/palace/repository.ts:

  • addTriple(userId, productId, wingId, subject, predicate, object, confidence) — assert a fact
  • invalidateTriple(userId, productId, subject, predicate, object) — mark fact as ended
  • queryEntity(userId, productId, entity, asOf?) → all current triples about entity
  • queryRelation(userId, productId, subject, predicate) → matching objects
  • timeline(userId, productId, entity) → chronological story
  • contradictions(userId, productId, wingId?) → conflicting current triples

When two notes reference the same entity, suggest a note-relationship:

// After KG extraction, check if entities appear in other notes' KG triples
// If so, suggest creating a note-relationship (existing module)

N3.4 Tests

  • Triple CRUD with user isolation
  • Temporal queries (point-in-time vs current)
  • Entity timeline returns chronological order
  • Contradiction detection finds conflicting facts
  • Invalidated triples excluded from current queries
  • KG auto-link suggests note relationships
  • Cross-user: user A's KG never leaks to user B

Phase N4 — Auto-Save Hooks (Event-Driven Memory Capture)

Goal: Automatically extract and store memories when notes change.

Estimated: 3 days | Modified files: 3 | Tests: ~10

N4.1 Save Triggers

Trigger Where What Happens
Note created notes/routes.ts POST Extract memories + KG from note body
Note updated notes/routes.ts PATCH Incremental extraction from changed content
Agent action completed note-agent-actions/routes.ts Extract memories from agent output
Prompt executed note-prompts/routes.ts run Extract memories from prompt result
Explicit store MCP mempalace_store tool User/agent-directed memory storage

N4.2 Post-Save Hook

// In notes/routes.ts, after successful create/update:
async function onNoteSaved(userId: string, productId: string, note: NoteDoc): Promise<void> {
  try {
    const wing = await ensureWing(userId, productId, note.workspaceId, workspaceName);
    const memories = await extractMemories(note.body, note.title, workspaceName);

    for (const mem of memories) {
      const room = await ensureRoom(userId, productId, wing.id, mem.roomSlug);
      const embedding = await embedText(mem.content);
      const isDup = await isNearDuplicate(userId, productId, room.id, mem.hall, mem.content, embedding);
      if (!isDup) {
        await storeMemory(userId, productId, wing.id, room.id, mem.hall, mem.content, note.id, embedding);
      }
    }

    // Regenerate L1 cache for this wing
    await regenerateCriticalFacts(userId, productId, wing.id);
  } catch (err) {
    // Best-effort — don't fail the note save
    req.log.warn({ err }, 'palace: memory extraction failed');
  }
}

N4.3 Tests

  • 0af5f87 Note create triggers memory extraction
  • 0af5f87 Note update triggers incremental extraction (same hook, re-extracts)
  • 0af5f87 Duplicate memories are skipped
  • 0af5f87 Extraction failure doesn't fail the note save
  • 0af5f87 Wing auto-created from workspace on first save
  • 0af5f87 Room auto-created from extracted topic
  • 0af5f87 Memories include sourceNoteId back-reference
  • 0af5f87 Cross-user isolation: user A save never creates user B memories

Phase N5 — Palace API Routes

Goal: REST endpoints for palace operations, secured with JWT auth.

Estimated: 3 days | New files: 1 | Tests: ~14

N5.1 Routes (modules/palace/routes.ts)

All routes extract userId from JWT via getUserId(req):

// Palace search
GET    /api/palace/search?q=...&wingId=...&limit=10

// Wings
GET    /api/palace/wings
GET    /api/palace/wings/:wingId
DELETE /api/palace/wings/:wingId

// Rooms
GET    /api/palace/wings/:wingId/rooms

// Memories
POST   /api/palace/memories         // explicit store
GET    /api/palace/memories?wingId=...&roomId=...&hall=...
DELETE /api/palace/memories/:id

// Knowledge Graph
GET    /api/palace/kg/entity/:entity
GET    /api/palace/kg/timeline/:entity
GET    /api/palace/kg/contradictions

// Wake-up context
GET    /api/palace/wake-up/:wingId?task=...

// Maintenance
POST   /api/palace/backfill-embeddings
POST   /api/palace/prune
GET    /api/palace/health
GET    /api/palace/stats

N5.2 Zod Schemas

export const PalaceSearchQuerySchema = z.object({
  q: z.string().min(1).max(500),
  wingId: z.string().max(128).optional(),
  roomId: z.string().max(128).optional(),
  hall: z.enum(HALL_TYPES).optional(),
  limit: z.coerce.number().int().min(1).max(50).default(10),
});

export const StoreMemorySchema = z.object({
  wingId: z.string().min(1).max(128),
  roomId: z.string().min(1).max(128),
  hall: z.enum(HALL_TYPES),
  content: z.string().min(1).max(5000),
  sourceNoteId: z.string().max(128).optional(),
});

N5.3 Tests

  • Search returns user-scoped results
  • Wings list only returns requesting user's wings
  • Explicit memory store persists and is immediately searchable
  • Delete wing cascades correctly
  • Wake-up context returns structured L0+L1+L2
  • KG entity query returns user-scoped triples
  • Stats endpoint returns accurate counts per user
  • Unauthenticated requests return 401
  • Wrong productId returns empty/403

Phase N6 — MCP Memory Tools

Goal: Extend NoteLett's existing MCP system with palace tools.

Estimated: 2 days | Modified files: 2 | New files: 1 | Tests: ~10

N6.1 New MCP Tools

Added to mcp/ alongside existing note tools:

Tool Description
mempalace_search Semantic + text hybrid search across user's palace
mempalace_store Explicitly store a memory (agent-directed)
mempalace_wake_up Get wake-up context for a workspace/wing
mempalace_query_entity Query KG triples about an entity
mempalace_timeline Chronological story of an entity
mempalace_list_wings List user's palace wings with stats

N6.2 User Isolation in MCP

Same pattern as existing MCP tools:

async function executeMempalaceSearch(args: MempalaceSearchInput, req: NotesMcpRequest) {
  const userId = requireUserId(req);  // JWT-enforced
  const productId = PRODUCT_ID;
  return searchHybrid(userId, productId, args.query, embedding, args.wingId, args.limit);
}

N6.3 Tests

  • Each MCP tool returns valid responses
  • mempalace_search returns ranked results
  • mempalace_store persists and is searchable
  • mempalace_wake_up returns structured context
  • MCP tools enforce userId from JWT (user A can't query user B)
  • Graceful error when palace is empty

Phase N7 — Web UI Integration

Goal: Surface palace data in the NoteLett web dashboard.

Estimated: 4 days | New files: 4 React | Tests: ~8

N7.1 Web API Client

New file: web/src/lib/palace-client.ts

export async function searchPalace(query: string, wingId?: string): Promise<PalaceMemory[]>;
export async function listWings(): Promise<PalaceWing[]>;
export async function getEntityTimeline(entity: string): Promise<TimelineEntry[]>;
export async function getWakeUpContext(wingId: string): Promise<WakeUpContext>;
export async function getPalaceStats(): Promise<PalaceStats>;

N7.2 React Components

  • PalacePanel — sidebar tab showing palace search + recent memories
  • KnowledgeGraphView — entity-relationship visualization for a workspace
  • MemoryTimeline — chronological view of extracted memories
  • PalaceStats — dashboard card showing wing counts, memory counts, KG size

N7.3 Integration with Existing UI

  • Note detail page — show "Related Memories" panel from palace search
  • Workspace page — show wing stats + recent memories
  • Search page — add "Search Palace" tab alongside note search

N7.4 Tests

  • Palace search renders results
  • Wings list shows user's workspaces as wings
  • Knowledge graph renders entity nodes
  • Empty state renders gracefully

Summary

Phase Description Days Tests Depends On
N0 @bytelyst/palace shared package in common-plat (types, cosine, dedup, decay, extraction, KG, wakeup) — d1c6cf4 3-4 91
N1 Palace Core (Cosmos containers, repository, extraction, dedup, encryption) — consumes @bytelyst/palace 4-5 ~30 N0
N2 Wake-Up Context (L0/L1/L2 at ~600 tokens) 3 ~12 N1
N3 Knowledge Graph (temporal entity triples) 4 ~16 N1
N4 Auto-Save Hooks (note create/update/agent action triggers) 3 ~10 N1
N5 Palace API Routes (REST endpoints + Zod validation) 3 ~14 N1, N2, N3
N6 MCP Memory Tools (6 mempalace_* tools) 2 ~10 N1, N5
N7 Web UI (palace panel, KG view, timeline, stats) 4 ~8 N5
Total 27-32 days ~125

New Dependencies (in NoteLett)

"@bytelyst/palace": "workspace:*"

(Added to backend/package.json — consumed alongside existing @bytelyst/datastore, @bytelyst/llm, @bytelyst/field-encrypt, etc.)

New Cosmos Containers

Container Partition Key Purpose
palace_wings /userId Per-user workspace→wing mapping
palace_rooms /userId Topic rooms within wings
palace_memories /userId Core memory storage with embeddings
palace_tunnels /userId Cross-room links
palace_kg /userId Knowledge graph triples
palace_diaries /userId Role-scoped diary entries

New Files

# ── In learning_ai_common_plat (Phase N0) ──────────────────────
packages/palace/
├── src/
│   ├── index.ts, types.ts, halls.ts, cosine.ts, dedup.ts,
│   ├── decay.ts, extraction.ts, kg.ts, wakeup.ts, config.ts
│   └── __tests__/ (6 test files)
├── package.json            # @bytelyst/palace
└── tsconfig.json

# ── In learning_ai_notes (Phases N1-N7) ────────────────────────
backend/src/modules/palace/
├── types.ts           # NoteLett-specific palace types (extends @bytelyst/palace base types)
├── repository.ts      # User-scoped CRUD + search (uses @bytelyst/palace cosine, dedup, decay)
├── extractor.ts       # LLM extraction (uses @bytelyst/palace prompts + @bytelyst/llm)
├── wakeup.ts          # Wake-up context (uses @bytelyst/palace wakeup builder)
├── routes.ts          # Fastify REST routes (uses @bytelyst/fastify-auth for user isolation)
└── palace.test.ts     # Vitest tests (uses @bytelyst/testing helpers)

backend/src/mcp/
├── palace-tool-contracts.ts   # Zod schemas for MCP tools
└── palace-tools.ts            # 6 MCP tool implementations

web/src/lib/
└── palace-client.ts   # Typed API client (uses @bytelyst/api-client)

web/src/components/
├── PalacePanel.tsx    # Sidebar palace search + memories (uses @bytelyst/ui)
├── KnowledgeGraphView.tsx
├── MemoryTimeline.tsx
└── PalaceStats.tsx

New Config Variables

Variable Default Description
PALACE_ENABLED true Enable/disable palace feature
PALACE_EXTRACTION_ENABLED true Enable auto-extraction on note save
PALACE_WAKE_UP_BUDGET 600 Max tokens for wake-up context
PALACE_RELEVANCE_HALF_LIFE_DAYS 90 Days for relevance to halve
PALACE_DEDUP_THRESHOLD 0.90 Cosine similarity threshold for dedup

Critical Path

N1 (Palace Core + extraction + dedup)
 ├──→ N2 (Wake-Up) ──→ N5 (API Routes) ──→ N6 (MCP Tools)
 ├──→ N3 (Knowledge Graph) ──→ N5                    ↘
 ├──→ N4 (Auto-Save) — independent after N1            N7 (Web UI)
 └──→ N6, N7 — last, needs N5

Note: N3 is optional for N2 (L1 works without KG; triples added once N3 is done).
N4 can be built in parallel with N2/N3.

Recommended execution order: N1 → N4 → N2 → N3 → N5 → N6 → N7

This gives auto-saving memories on note create/update after N1+N4 (~9 days), with semantic wake-up and KG following.


User Isolation Checklist

Every phase must satisfy these checks before merging:

  • Every new Cosmos container uses /userId as partition key
  • Every repository function takes userId as first parameter
  • Every query filter includes userId — no exceptions
  • Every route handler extracts userId via getUserId(req) — never from request body/params
  • Every test file includes at least one cross-user isolation test
  • No endpoint accepts userId as a URL parameter or request body field
  • Field encryption uses userId in encryption context
  • MCP tools use requireUserId(req) guard (from existing pattern)