/** * pi-loom standalone MCP stdio server * * Reuses handlers.ts for all tool logic. No pi runtime needed. * Usage: npx pi-loom */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { handleLoomAudit, handleLoomApply, handleLoomCheckPath, handleLoomConsolidate, handleLoomConstrain, handleLoomContext, handleLoomDetail, handleLoomDream, handleLoomEpisode, handleLoomEvidence, handleLoomExtract, handleLoomGraph, handleLoomInsights, handleLoomLink, handleLoomManageInsight, handleLoomMermaid, handleLoomOffload, handleLoomOffloadRecall, handleLoomProfile, handleLoomRecall, handleLoomRelated, handleLoomReview, handleLoomSearch, handleLoomSetup, handleLoomStats, handleLoomStatus, handleLoomStore, handleLoomSummarizeSession, handleLoomTimeline, handleLoomViews, } from "./handlers.js"; import { LoomStore, openDb } from "./store.js"; const store = new LoomStore(openDb()); const server = new McpServer({ name: "pi-loom", version: "0.5.0" }); const mcpCtx = null as any; console.error(`[pi-loom] MCP ready. ${store.stats().active} active memories.`); // ── 30 tools ─────────────────────────────────────────── server.registerTool( "loom_store", { description: "Store an observation, decision, error, or file change into cross-session memory. Call AFTER: architectural decisions (importance=0.8+), errors and their fixes (0.7), file edits with rationale (0.5-0.6), task completions, constraints discovered. Entity anchoring via entity_id makes future recall far more accurate — use ESR entity IDs when available.", inputSchema: { content: z.string().describe("Memory content"), fact_summary: z.string().optional().describe("LLM-extracted short fact summary"), entity_id: z.string().optional().describe("ESR entity ID to anchor to"), kind: z .enum(["memory", "fact", "decision", "episode", "procedure", "profile", "insight", "constraint", "handoff"]) .optional() .describe("Lightweight memory kind. Defaults to memory; use procedure for reusable coding playbooks."), scope_type: z .enum(["user", "repo", "task", "session", "entity"]) .optional() .describe("Memory scope. Defaults to repo."), scope_id: z.string().optional().describe("Scope identifier, e.g. repo name, task ID, or session ID"), confidence: z.number().min(0).max(1).optional().describe("Evidence confidence. Defaults to 1.0 for direct memories."), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project."), importance: z.number().min(0).max(1).optional().default(0.5), expire_at: z.string().optional().describe("ISO 8601 expiration timestamp"), tags: z.array(z.string()).optional().describe("Tags for filtering"), auto_extract: z .boolean() .optional() .describe("Auto-extract atomic facts from this memory (requires importance >= 0.6)"), auto_link_entities: z .boolean() .optional() .describe( "Auto-link known entity_ids found in content (default: true, no effect if entity_id is explicitly set)", ), }, }, async (args: any) => handleLoomStore(store, args, mcpCtx), ); server.registerTool( "loom_recall", { description: "Recall past memories by entity or text search. ALWAYS call before making architecture decisions, touching code you did not write, or answering questions about past work. Results ranked by 5-signal fusion: FTS5 keyword + vector semantic + recency decay + entity graph proximity + cross-session recurrence.", inputSchema: { entity_id: z.string().optional().describe("Filter by ESR entity ID"), query: z.string().optional().describe("Free-text search in memory content"), view: z .enum(["default", "procedures", "handoffs", "profiles", "insights", "decisions"]) .optional() .describe("Recall view over the same MemoryNode table."), scope_type: z.enum(["user", "repo", "task", "session", "entity"]).optional().describe("Scope filter for view recalls"), scope_id: z.string().optional().describe("Scope identifier for view recalls"), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), limit: z.number().optional().default(20).describe("Max results"), compact: z.boolean().optional().describe("Skip metadata, return content only (saves tokens)"), }, }, async (args: any) => handleLoomRecall(store, args), ); server.registerTool( "loom_search", { description: "Three-way hybrid search: FTS5 keyword + vector semantic + recency weighting. Use mode='semantic' for conceptual queries like 'that auth bug', mode='keyword' for exact terms, mode='hybrid' for both.", inputSchema: { query: z.string().describe("Search query"), mode: z .enum(["keyword", "semantic", "hybrid"]) .optional() .default("hybrid") .describe("Search mode: keyword (FTS5 only), semantic (vector only), hybrid (RRF fusion)"), entity_id: z.string().optional().describe("Filter by ESR entity ID"), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), limit: z.number().optional().default(20).describe("Max results"), compact: z.boolean().optional().describe("Skip metadata, return content only (saves tokens)"), explain: z.boolean().optional().describe("Return per-signal score breakdown for debugging retrieval ranking"), }, }, async (args: any) => handleLoomSearch(store, args), ); server.registerTool( "loom_dream", { description: "Run Dream Engine for insight generation. Uses weighted sampling, conflict detection, shuffling, and LLM-driven insight generation.", inputSchema: { rounds: z.number().optional().default(2).describe("Number of sampling rounds"), per_round: z.number().optional().default(10).describe("Memories per round"), model: z .string() .optional() .describe("Provider/model, e.g. 'deepseek/deepseek-v3.1'. Also via PI_DREAM_MODEL env."), entity_id: z.string().optional().describe("Filter sampling to a specific ESR entity and its graph neighbors"), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), }, }, async (args: any) => handleLoomDream(store, args, mcpCtx), ); server.registerTool( "loom_insights", { description: "View Dream Engine insights, optionally filtered by entity.", inputSchema: { entity_id: z.string().optional().describe("Filter insights by ESR entity"), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), limit: z.number().optional().default(10).describe("Max insights"), }, }, async (args: any) => handleLoomInsights(store, args), ); server.registerTool( "loom_manage_insight", { description: "Update or delete an insight. Use action='update' to refine content/confidence/entity, or action='delete' to remove.", inputSchema: { action: z.string().describe("'update' (default) or 'delete'"), insight_id: z.string().describe("Insight ID (from loom_insights output)"), content: z.string().optional().describe("[update] Updated insight text"), confidence: z.number().min(0).max(1).optional().describe("[update] New confidence 0.0-1.0"), entity_id: z.string().optional().describe("[update] Bind/unbind to ESR entity"), reason: z.string().optional().describe("[delete] Why it's being removed"), }, }, async (args: any) => handleLoomManageInsight(store, args), ); server.registerTool( "loom_extract", { description: "Extract atomic facts from memories. Stores facts as searchable memories indexed by FTS5.", inputSchema: { entity_id: z.string().optional().describe("Extract from memories with this entity_id"), memory_ids: z.array(z.string()).optional().describe("Specific memory IDs to extract from"), model: z .string() .optional() .describe("Provider/model for extraction. Set PI_FACT_MODEL env to override default."), limit: z.number().optional().default(20).describe("Max memories to process"), }, }, async (args: any) => handleLoomExtract(store, args, mcpCtx), ); server.registerTool( "loom_stats", { description: "View pi-loom memory statistics.", inputSchema: {}, }, async () => handleLoomStats(store), ); server.registerTool( "loom_status", { description: "Lightweight session status (~50 tokens). Returns active/degraded counts, last 3 high-signal memories, and latest insight. ⚡ Call at session start — cheaper than full loom_recall, may surface critical context immediately.", inputSchema: {}, }, async () => handleLoomStatus(store), ); server.registerTool( "loom_context", { description: "Build the [PI_LOOM] symbolic context index (~520 tokens). Compact summary of past decisions, errors, recent memories, and insights. Inject this into your system prompt at session start to give the agent cross-session awareness without consuming significant context. Use loom_detail(id) to drill down into any entry.", inputSchema: { scope_type: z.enum(["user", "repo", "task", "session", "entity"]).optional().describe("Scope type for context planning"), scope_id: z.string().optional().describe("Scope identifier for context planning"), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), max_tokens: z.number().optional().describe("Soft token budget for context planning"), }, }, async (args: any) => handleLoomContext(store, args), ); server.registerTool( "loom_profile", { description: "View or generate entity profiles — aggregated portraits synthesized from facts by Dream Engine. TriMem-inspired: complements atomic facts with holistic semantic understanding. Without entity_id, lists all profiles.", inputSchema: { entity_id: z.string().optional().describe("Entity ID to view profiles for"), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), limit: z.number().optional().default(10).describe("Max profiles"), }, }, async (args: any) => handleLoomProfile(store, args), ); server.registerTool( "loom_setup", { description: "⭐ START HERE — one-call session init. Returns loom_status + loom_context (~600 tokens). ALWAYS call at session start to surface what the agent remembers from past sessions. Use loom_recall(query) to drill deeper into specific topics.", inputSchema: { scope_type: z.enum(["user", "repo", "task", "session", "entity"]).optional().describe("Scope type for context planning"), scope_id: z.string().optional().describe("Scope identifier for context planning"), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), max_tokens: z.number().optional().describe("Soft token budget for context planning"), }, }, async (args: any) => handleLoomSetup(store, args), ); server.registerTool( "loom_review", { description: "Generate deterministic memory maintenance proposals (merge, supersede, promote_to_procedure, archive, contradicts). Proposal-only: does not modify memory.", inputSchema: { scope_type: z.enum(["user", "repo", "task", "session", "entity"]).optional().describe("Scope type to review"), scope_id: z.string().optional().describe("Scope identifier to review"), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), limit: z.number().optional().default(20).describe("Max proposals"), }, }, async (args: any) => handleLoomReview(store, args), ); server.registerTool( "loom_apply", { description: "Explicitly apply a review proposal. This can archive memories, create procedure memories, or link contradictions; call only after reviewing loom_review output.", inputSchema: { action: z.enum(["merge", "supersede", "promote_to_procedure", "archive", "contradicts"]).describe("Proposal action to apply"), memory_ids: z.array(z.string()).describe("Memory IDs from loom_review output. For supersede: [old, new, optional evidence...]."), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), }, }, async (args: any) => handleLoomApply(store, args), ); server.registerTool( "loom_views", { description: "Export read-only Markdown views from the MemoryNode table. SQLite remains the source of truth; files are deterministic projections for inspection and handoff.", inputSchema: { scope_type: z.enum(["user", "repo", "task", "session", "entity"]).optional().describe("Scope type to export"), scope_id: z.string().optional().describe("Scope identifier to export"), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), limit: z.number().optional().default(50).describe("Max memories per view"), }, }, async (args: any) => handleLoomViews(store, args), ); server.registerTool( "loom_evidence", { description: "Query-time evidence distillation (DeferMem-inspired). High-recall search + query-conditioned synthesis. Unlike loom_search which returns raw memories, loom_evidence produces a 1-3 sentence answer grounded in retrieved facts with source citations. Best for: 'what was decided about X', 'what happened with Y'.", inputSchema: { query: z.string().describe("Question or topic to find evidence for"), entity_id: z.string().optional().describe("Filter by ESR entity ID"), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), }, }, async (args: any) => handleLoomEvidence(store, args, mcpCtx), ); server.registerTool( "loom_consolidate", { description: "Consolidate subconscious memories that have hit the recurrence threshold (hit_count >= 3). Runs full pipeline: find candidates → verify similarity → LLM synthesis → store consolidated memory. Subconscious memories enter via auto-capture; only recurrent patterns trigger consolidation. Zero LLM cost until consolidation fires.", inputSchema: {}, }, async (args: any) => handleLoomConsolidate(store, args, mcpCtx), ); server.registerTool( "loom_audit", { description: "View raw tool event logs for a session. Without session_id, lists recent sessions. Full audit trail of all tool calls — zero LLM cost.", inputSchema: { session_id: z.string().optional().describe("Session ID to view. Omit to list recent sessions."), event_type: z.string().optional().describe("Filter by event type: tool_result, user_message, agent_message"), limit: z.number().optional().default(50).describe("Max events to return"), }, }, async (args: any) => handleLoomAudit(store, args), ); server.registerTool( "loom_summarize_session", { description: "Generate an LLM summary of a session from raw event logs. Extracts decisions, errors, file changes, and unfinished tasks as structured memories. Call at END of each session to preserve what happened for future sessions. Without session_id, auto-selects the most recent session.", inputSchema: { session_id: z.string().optional().describe("Session to summarize. Defaults to most recent session."), }, }, async (args: any) => handleLoomSummarizeSession(store, args, mcpCtx), ); // ── Progressive Disclosure (Phase 3) ────────────────────── server.registerTool( "loom_detail", { description: "Get full content of a memory by ID. Use the #mem_xxx IDs from the [PI_LOOM] context index. Drill-down step 1: get complete memory text.", inputSchema: { memory_id: z.string().describe("Memory ID (full or first 8 chars) from [PI_LOOM] context index"), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), }, }, async (args: any) => handleLoomDetail(store, args), ); server.registerTool( "loom_timeline", { description: "Get chronological timeline of all memories for an entity. Without entity_id, lists top entities. Drill-down step 2: browse entity history.", inputSchema: { entity_id: z.string().optional().describe("ESR entity ID. Omit to list entities with most memories."), visibility: z.enum(["private", "project", "shared"]).optional().describe("Visibility boundary. Defaults to project/shared."), limit: z.number().optional().default(20).describe("Max entries"), }, }, async (args: any) => handleLoomTimeline(store, args), ); server.registerTool( "loom_episode", { description: "Get full session summary: decisions, errors, changes, unfinished tasks. Without session_id, lists recent sessions. Drill-down step 3: understand what happened in a session.", inputSchema: { session_id: z.string().optional().describe("Session ID. Omit to list recent session summaries."), }, }, async (args: any) => handleLoomEpisode(store, args), ); // ── Entity Graph (Phase 1.2) ────────────────────────────── server.registerTool( "loom_link", { description: "Create a typed relation between two entities. Types: DEPENDS_ON, MODIFIES, REFERENCES, RESOLVES, USES, AFFECTED_BY, RELATES_TO.", inputSchema: { source_entity: z.string().describe("Source entity ID"), target_entity: z.string().describe("Target entity ID"), relation_type: z.string().describe("Relation type"), memory_id: z.string().optional().describe("Memory this relation was derived from"), confidence: z.number().min(0).max(1).optional().default(0.5).describe("Confidence 0-1"), }, }, async (args: any) => handleLoomLink(store, args), ); server.registerTool( "loom_related", { description: "List all entities related to an entity via typed edges. Both incoming and outgoing.", inputSchema: { entity_id: z.string().describe("Entity ID to query"), }, }, async (args: any) => handleLoomRelated(store, args), ); server.registerTool( "loom_graph", { description: "Traverse entity graph via BFS up to N hops. Returns all entity IDs reachable. Used to expand retrieval context.", inputSchema: { entity_id: z.string().describe("Starting entity ID"), hops: z.number().optional().default(2).describe("Max hops (1-3)"), }, }, async (args: any) => handleLoomGraph(store, args), ); // v1.0: Path-conditioned constraint tools server.registerTool( "loom_check_path", { description: "Check path-conditioned constraints against raw_events. Returns violations for constraints whose conditions (e.g. 'tool:bash error>=3,window=300') are met in the event log. Zero LLM cost.", inputSchema: { entity_id: z.string().optional().describe("Filter to specific entity's constraints"), session_id: z.string().optional().describe("Filter to specific session"), }, }, async (args: any) => handleLoomCheckPath(store, args), ); server.registerTool( "loom_constrain", { description: "Create a path-conditioned constraint that watches raw_events. path_condition format: 'PREFIX[ tag]>=N,window=SECONDS' (e.g. 'tool:bash error>=3,window=300'). Set path_condition to null for a static-only constraint.", inputSchema: { entity_id: z.string().describe("ESR entity to bind constraint to"), description: z.string().describe("Constraint description"), path_condition: z .string() .optional() .describe("Path condition e.g. 'tool:bash error>=3,window=300' or omit for static"), enforcement: z.enum(["warn", "block", "log"]).optional().default("warn").describe("Action on violation"), }, }, async (args: any) => handleLoomConstrain(store, args), ); server.registerTool( "loom_offload", { description: "Offload large text to external file, returning a symbolic [REF:node_id] reference. Keeps context lean.", inputSchema: { content: z.string().describe("Text to offload"), session_id: z.string().optional().default("default"), label: z.string().optional().default("offload"), max_inline: z.number().optional().default(500), entity_id: z.string().optional(), }, }, async (args: any) => handleLoomOffload(store, args), ); server.registerTool( "loom_offload_recall", { description: "Retrieve offloaded content by node_id.", inputSchema: { node_id: z.string().describe("Node ID from [REF:node_id]"), session_id: z.string().optional(), }, }, async (args: any) => handleLoomOffloadRecall(args), ); server.registerTool( "loom_mermaid", { description: "Generate a Mermaid task graph from offloaded session refs.", inputSchema: { session_id: z.string().optional().default("default"), }, }, async (args: any) => handleLoomMermaid(store, args), ); const transport = new StdioServerTransport(); await server.connect(transport);