/** * pi-loom: Memory Store — SQLite-backed storage with temporal semantics * * Tables: * memories - all memory types with provenance + derivation DAG * memories_fts - FTS5 full-text search with porter stemming * vec_memories - sqlite-vec KNN vector search (1536 dims) * entity_edges - typed relations between ESR entities * raw_events - immutable event audit log * session_summaries - LLM-generated session digests * episodes - session-level temporal summaries * * Types and helpers have been extracted to types.ts. */ import type Database from "better-sqlite3"; import type { GraphProvider } from "./graph-provider.js"; import { ConstraintStore, EpisodeStore, RawEventStore } from "./store-modules.js"; import type { TemporalFilter } from "./temporal.js"; import type { ConstraintRow, DerivationLink, EntityEdgeRow, EpisodeRow, MemoryEdgeRow, MemRow, MemStatus, Provenance, RawEventRow, SessionSummaryRow } from "./types.js"; import { estimateTokens, getDbDir, getDbPath, openDb, parseTags } from "./types.js"; export type { ConstraintRow, DerivationLink, EntityEdgeRow, EpisodeRow, MemoryEdgeRow, MemRow, MemStatus, Provenance, RawEventRow, SessionSummaryRow, }; export { estimateTokens, getDbDir, getDbPath, openDb, parseTags }; export interface HybridSearchParams { query?: string; queryEmbedding?: number[]; entity_id?: string; /** Pre-filter: only search memories belonging to this entity_id. */ entity_filter?: string; /** Pre-filter: only return memories in this MemoryNode scope. */ scope_type?: string; /** Pre-filter: only return memories for this scope_id; global scope_id NULL remains eligible. */ scope_id?: string; /** Visibility boundary. Defaults to project recall: project/shared only. */ visibility?: VisibilityFilter; limit?: number; compact?: boolean; weights?: { fts5?: number; vector?: number; recency?: number; graph?: number; access?: number; }; temporal?: TemporalFilter; provenance_filter?: Provenance[]; } export type VisibilityFilter = "private" | "project" | "shared"; export interface HybridSignalScores { fts5: number; vec: number; recency: number; graph: number; access: number; hop: number; } export interface HybridSearchResult { mem: MemRow; score: number; scores: HybridSignalScores; } export interface LoomHealthCheck { dbPath: string; activeMemories: number; ftsRows: number; ftsSynced: boolean; vecLoaded: boolean; embeddingRows: number; embeddingCoverage: number; rawEvents: number; entityEdges: number; hasEmbedConfig: boolean; hasLocalEmbedConfig: boolean; hasDreamModel: boolean; hasFactModel: boolean; } export interface LoomReviewProposal { action: "merge" | "supersede" | "promote_to_procedure" | "archive" | "contradicts"; memory_ids: string[]; reason: string; confidence: number; } export interface LoomReviewResult { scope?: { scope_type?: string; scope_id?: string; }; proposals: LoomReviewProposal[]; } export interface LoomApplyResult { action: LoomReviewProposal["action"]; memory_ids: string[]; archived_ids: string[]; created_ids: string[]; edge_ids: string[]; } export declare class LoomStore { private db; private _vecLoaded; readonly graphProvider: GraphProvider; readonly rawEvents: RawEventStore; readonly episodes: EpisodeStore; readonly constraints: ConstraintStore; constructor(db: Database.Database, graphProvider?: GraphProvider); private _ensureMemoryGraphSchema; /** Set session context for provenance tracking. Called by bridge/extension. */ setSessionId(sid: string): void; private _checkVecLoaded; /** Whether vector search via sqlite-vec is available. */ get vecLoaded(): boolean; store(params: { content: string; fact_summary?: string; entity_id?: string; kind?: string; scope_type?: string; scope_id?: string; confidence?: number; visibility?: string; importance?: number; valid_at?: string; expire_at?: string; tags?: string[]; provenance?: Provenance; derivation?: DerivationLink[]; }): MemRow; /** Generate embedding for content and store in vec_memories. Async, fire-and-forget. */ private _embedAndStore; get(id: string): MemRow | undefined; private _extractCodingEntityIds; /** * Lightweight entity_id extraction: scan content for known entity_ids and * coding-specific entities. Zero LLM cost — pure pattern matching. * Returns list of entity_ids found in the content. */ extractEntityIds(memoryId: string): string[]; /** * TrustMem-inspired lightweight memory trust validator. * Zero LLM cost — uses pattern matching and FTS5 similarity. * Returns a trust score 0-1 and list of potential conflicts. */ checkTrust(memoryId: string): { score: number; conflicts: Array<{ id: string; content: string; reason: string; }>; warnings: string[]; }; updateStatus(id: string, status: MemStatus): void; updateImportance(id: string, importance: number): void; expireOverdue(): number; /** Called whenever a memory is returned from any recall/search. Extends TTL. */ private _trackRecall; /** Compute recency score for RRF (0=oldest, 1=most recent). */ private _recencyScore; /** * Compute access-frequency decay score for retrieval ranking. * Mirrors Mem0 v3 Memory Decay: frequently-accessed memories surface higher. * * Formula: log2(1 + recall_count) / (1 + 0.05 * days_since_access) * - recall_count=1, accessed now → ~0.69 * - recall_count=5, accessed now → ~1.97 * - recall_count=1, accessed 30d → ~0.28 * - recall_count=5, accessed 30d → ~0.79 * * Range: 0.0–3.0+, clamped to [0, 2.0] for stable RRF contribution. */ private _accessScore; /** * v1.0: Derivation boost — reward memories with provable provenance chains. * Direct-from-event gets +0.05, multi-source consensus gets +0.03, * weak/low-weight derivations get slight penalty. * Range: ~ -0.02 to +0.08 (small relative to 0.0–1.0 signal scores). */ private _derivationBoost; /** v1.1: Score fusion — additive (Mem0-style) or RRF weighted sum. */ private _fuseScores; /** * v1.0: Consolidation quality gate — zero LLM cost. * * 回应论文 "Useful Memories Become Faulty When Continuously Updated by LLMs" * (arXiv:2605.12978): LLM 巩固会丢失关键细节甚至产生虚假记忆。 * * 检查:提取源记忆的关键术语(>3 chars),检查它们在巩固结果中的覆盖率。 * 覆盖率 < 0.35 → 标记为 'consolidation-degraded',importance 降至 0.6x。 * 不拒绝巩固(我们不够信号),但降低检索优先级。 * * 返回 degradation tag 或 null。 */ private _consolidationQualityCheck; recallByEntity(entityId: string, limit?: number, visibility?: VisibilityFilter): MemRow[]; recallActive(limit?: number, visibility?: VisibilityFilter): MemRow[]; private _matchesScope; private _matchesVisibility; private _derivesFromPrivate; private _rankActiveFallback; /** * Recall active memories filtered by tags. * Most relevant for context injection — e.g. tags: ["decision","architecture"]. */ recallByTags(tags: string[], limit?: number, visibility?: VisibilityFilter): MemRow[]; recallByKind(kind: string, limit?: number, scopeType?: string, scopeId?: string, visibility?: VisibilityFilter): MemRow[]; /** * FTS5 full-text search with BM25 ranking (Mem0-style keyword matching). * Uses porter stemming — "attending" matches "attend", "meetings" matches "meeting". * Falls back to LIKE search for short/partial queries that FTS5 can't handle. */ search(query: string, limit?: number, entityFilter?: string, temporal?: TemporalFilter, visibility?: VisibilityFilter): MemRow[]; /** * LIKE-based search (fallback for short queries or FTS5 parse errors). */ searchLike(query: string, limit?: number, temporal?: TemporalFilter, visibility?: VisibilityFilter): MemRow[]; private _filterVisible; /** * Store a memory in the subconscious layer. * * Subconscious memories have low importance (0.15), short TTL (3 days), * and are tagged "subconscious". They don't trigger LLM extraction on their own — * only after hit_count reaches 3+ do they become consolidation candidates. */ /** * Category-based consolidation thresholds. * Errors get immediate attention (hit≥2). File edits within same session * just get deduped (no LLM). Cross-session everything uses lower thresholds. */ private static readonly CATEGORY_THRESHOLDS; private static inferCategory; storeSubconscious(params: { content: string; entity_id?: string; tags?: string[]; session_id?: string; }): MemRow; /** * Bump hit_count on existing subconscious memories that are similar to * a newly captured memory. Uses cheap LIKE-based overlap (no embedding call): * - Same entity_id * - Overlapping tags * - First 50 chars of content match * * Returns count of bumped memories. */ bumpSimilarHits(params: { content: string; entity_id?: string | null; tags?: string[]; session_id?: string; }): number; /** * Find subconscious memories that have accumulated enough hits for consolidation. * Uses category-aware thresholds: errors at 2, git/esr at 3, edits at 8. * * When minSessionDistinct >= 2, only returns memories that have appeared * in at least that many different sessions (cross-session recurrence). */ /** * Discover active tag categories from subconscious memories. * Returns tags sorted by frequency. Used for auto-discovery in consolidation * loops instead of hardcoded category lists (e.g. ["git", "esr", "error"]). * * Filters out structural tags (subconscious, auto-captured, cat:*, consolidated-into:*). */ getActiveSubconsciousTags(minHitCount?: number, limit?: number): Array<{ tag: string; count: number; }>; findConsolidationCandidates(params?: { category?: string; limit?: number; minSessionDistinct?: number; }): MemRow[]; /** * Find similar subconscious memories using embedding similarity (sqlite-vec KNN). * Falls back to tag/entity/content overlap when vec is not loaded. * * @param sourceMem - the source memory to find similar ones for * @param threshold - cosine similarity threshold (default 0.85) * @param limit - max results */ findSimilarByEmbedding(sourceMem: MemRow, threshold?: number, limit?: number): Array<{ mem: MemRow; similarity: number; }>; /** Tag/entity/content overlap fallback when embeddings unavailable. */ private _findSimilarByOverlap; /** * Consolidate a group of similar subconscious memories into a single long-term memory. * * Creates a new memory with tags ["consolidated", "recurrence:N"] and boosted importance. * Source memories are tagged with "consolidated-into:" but remain active/searchable. */ consolidate(params: { sourceIds: string[]; content: string; fact_summary: string; entity_id?: string | null; importance: number; }): MemRow; /** * Phase 2.2: Cross-session recurrence boost. * * Detects query terms that recur across 3+ distinct sessions (by valid_at date). * When found, boosts memories from ALL sessions where the recurring topic appears, * ensuring later sessions aren't buried by early ones with higher BM25 scores. * * Example: query "WAL desync bug" → term "desync" appears in 5/6 sessions → * all sessions with "desync" memories get boosted, not just top-3 BM25 hits. */ private _crossSessionBoost; /** * Three-way hybrid search with Reciprocal Rank Fusion. * * Signals: * 1. FTS5 BM25 keyword matching (porter stemming) * 2. Vector semantic similarity (sqlite-vec KNN, cosine distance) * 3. Recency weighting (exponential decay, λ=0.1) * * RRF formula: score = α * fts5_norm + β * vec_norm + γ * recency_norm * Default weights: α=0.40, β=0.40, γ=0.20 * * Falls back gracefully when vec0 is not available (α=0.55, β=0, γ=0.45). */ searchHybrid(params: HybridSearchParams): MemRow[]; /** Return ranked hybrid search results with per-signal score breakdown. */ searchHybridExplain(params: HybridSearchParams): HybridSearchResult[]; /** * Full three-way hybrid search WITH pre-computed query embedding. * This is the primary method for semantic queries — call embedText() first, * then pass the result here. */ searchHybridWithEmbedding(params: HybridSearchParams): MemRow[]; /** Check if any embeddings are stored. */ hasEmbeddings(): boolean; /** * Embed an existing memory by its ID. Used for backfilling or explicit embedding. * Returns the embedding vector, or [] if embedding failed. */ embedMemory(id: string): Promise; /** * Bulk embed all active memories that don't have vectors yet. * Returns count of newly embedded memories. */ embedAll(batchSize?: number): Promise; /** JSON-based cosine similarity fallback (used when sqlite-vec not available). */ private _searchByEmbeddingJS; countByEntity(entityId: string): number; /** * Store an extracted fact as a memory (Mem0-style atomic fact storage). * Facts are regular memories with "extracted-fact" tag — they participate * in FTS5 search, searchHybrid, and context injection exactly like raw memories. * Short, keyword-dense facts naturally rank higher in BM25. * * @param parent - parent memory that this fact was extracted from * @param factText - the extracted atomic fact (1 sentence) */ storeFact(parent: MemRow, factText: string): MemRow; /** * Get all extracted facts for a parent memory. */ getFacts(parentId: string, limit?: number, visibility?: VisibilityFilter): MemRow[]; /** * Get extracted facts for an entity (Mem0-style entity fact recall). */ getFactsForEntity(entityId: string, limit?: number, visibility?: VisibilityFilter): MemRow[]; /** * Weighted random sampling: importance * recency. * Returns top 60% by score + 40% random from the rest. */ sampleWeighted(count: number, visibility?: VisibilityFilter): MemRow[]; /** * Entity-scoped sampling: importance * recency, filtered to one entity and its neighbors. * Used by Dream Engine with --entity-id. */ sampleByEntity(entityId: string, count: number, visibility?: VisibilityFilter): MemRow[]; private _sampleFromPool; /** * Find potential conflicts: memory pairs about the same entity * tagged as task-started vs task-completed, or other heuristics. */ findConflicts(limit?: number, visibility?: VisibilityFilter): MemRow[][]; storeRawEvent(params: { session_id: string; event_type: string; payload: Record; }): string; auditRawEvents(params: { session_id?: string; event_type?: string; limit?: number; offset?: number; }): RawEventRow[]; recentSessions(limit?: number): string[]; countRawEvents(sessionId: string): number; getRawEventsAsText(sessionId: string, maxEvents?: number): string; purgeRawEvents(): number; storeSessionSummary(params: { session_id: string; summary: string; decisions?: string[]; errors?: string[]; changes?: string[]; unfinished?: string[]; memory_ids?: string[]; }): string; getSessionSummary(sessionId: string): SessionSummaryRow | undefined; recentSessionSummaries(limit?: number): SessionSummaryRow[]; /** Update the memory_ids on a session summary. */ updateSummaryMemories(sid: string, memIds: string[]): void; /** Create an episode from a session summary or standalone. */ createEpisode(params: { session_id: string; summary?: string; entity_id?: string; token_count?: number; }): string; /** Get timeline of episodes for an entity, newest first. */ getTimeline(entityId: string, limit?: number): EpisodeRow[]; /** Link two entities with a typed relation. Delegates to graphProvider. */ linkEntities(params: { source_entity: string; target_entity: string; relation_type: string; memory_id?: string; episode_id?: string; confidence?: number; }): string; /** Get all relations for an entity (both directions). Delegates to graphProvider. */ getRelatedEntities(entityId: string): Array<{ entity: string; relation_type: string; confidence: number; direction: "out" | "in"; }>; /** * BFS graph traversal from start entities up to maxHops. * Delegates to graphProvider. Used to expand retrieval scope. */ traverseGraph(startEntities: string[], maxHops?: number): string[]; /** * Count how many episodes mention a set of entities. * Used to score graph expansion candidates. */ countEpisodesForEntities(entityIds: string[]): Map; linkMemories(params: { source_id: string; target_id: string; relation: string; confidence?: number; }): string; getMemoryEdges(memoryId: string): MemoryEdgeRow[]; listMemoryEdges(limit?: number): MemoryEdgeRow[]; private _hasMemoryEdge; review(params?: { scope_type?: string; scope_id?: string; visibility?: VisibilityFilter; limit?: number; }): LoomReviewResult; applyReviewProposal(params: { action: LoomReviewProposal["action"]; memory_ids: string[]; }): LoomApplyResult; stats(): { active: number; expired: number; archived: number; insight: number; totalInsights: number; tokenEstimate: number; degraded: number; expiringSoon: number; extendedByRecall: number; }; healthCheck(): LoomHealthCheck; storeInsight(params: { content: string; supporting_ids: string[]; confidence: number; entity_id?: string; step: number; visibility?: VisibilityFilter; }): MemRow; getInsights(limit?: number, visibility?: VisibilityFilter): MemRow[]; getInsightsForEntity(entityId: string, visibility?: VisibilityFilter): MemRow[]; getSupportingMemories(insightId: string, visibility?: VisibilityFilter): MemRow[]; updateInsight(id: string, params: { content?: string; confidence?: number; entity_id?: string | null; }): boolean; deleteInsight(id: string): boolean; getInsight(id: string): MemRow | undefined; /** * Store an entity profile portrait. Now uses provenance "dream_insight" * (merged with insights — profiles are insight sub-type tagged "entity_profile"). */ storeProfile(params: { entity_id: string; content: string; supporting_ids?: string[]; confidence?: number; visibility?: VisibilityFilter; }): MemRow; getProfiles(entityId: string, limit?: number, visibility?: VisibilityFilter): MemRow[]; getLatestProfile(entityId: string, visibility?: VisibilityFilter): MemRow | undefined; getAllProfiles(limit?: number, visibility?: VisibilityFilter): MemRow[]; /** * Store a path-conditioned constraint. * * path_condition format: "KEY>=N,window=SECONDS" or null for static-only. * KEY: event_type prefix, e.g. "tool:bash error" — * matches raw_events where event_type starts with the key * and payload contains '"isError":true' when tag "error" present. * N: threshold count to trigger * SECONDS: sliding window (default 300 = 5 min) * * Examples: * "tool:bash error>=3,window=300" → 3+ bash errors in 5 min → violation * null → static constraint (always active) */ storeConstraint(params: { entity_id: string; description: string; path_condition?: string; enforcement?: "warn" | "block" | "log"; }): ConstraintRow; /** Evaluate path-conditioned constraints against raw_events. Delegates to ConstraintStore. */ checkPathConstraints(params?: { entity_id?: string; session_id?: string; }): Array; /** List all constraints for an entity. */ getConstraints(entityId: string): ConstraintRow[]; }