/** * MI Routes — Hono sub-app for Mycelial Intelligence endpoints. * * POST /ask — main chat, uses provider registry * POST /triage — content triage via Gemini * GET /models — available models for frontend selector * POST /validate-actions — permission check for parsed actions */ import { Hono } from "hono"; import { miRegistry } from "./mi-provider"; import type { MiMessage } from "./mi-provider"; import { getModuleInfoList, getAllModules } from "../shared/module"; import { resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import type { EncryptIDClaims } from "@encryptid/sdk/server"; import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes"; import type { MiAction } from "../lib/mi-actions"; const mi = new Hono(); // ── GET /models — available models for frontend selector ── mi.get("/models", (c) => { const models = miRegistry.getAvailableModels(); const defaultModel = miRegistry.getDefaultModel(); return c.json({ models, default: defaultModel }); }); // ── POST /ask — main MI chat ── mi.post("/ask", async (c) => { const { query, messages = [], space, module: currentModule, context = {}, model: requestedModel } = await c.req.json(); if (!query) return c.json({ error: "Query required" }, 400); // ── Resolve caller role ── let callerRole: SpaceRoleString = "viewer"; let claims: EncryptIDClaims | null = null; try { const token = extractToken(c.req.raw.headers); if (token) { claims = await verifyEncryptIDToken(token); } } catch { /* unauthenticated → viewer */ } if (space && claims) { const resolved = await resolveCallerRole(space, claims); if (resolved) callerRole = resolved.role; } else if (claims) { // Authenticated but no space context → member callerRole = "member"; } // ── Resolve model ── const modelId = requestedModel || miRegistry.getDefaultModel(); let providerInfo = miRegistry.resolveModel(modelId); // Fallback: if the modelId is a raw provider model (e.g. "llama3.2:3b"), try Ollama if (!providerInfo) { const ollama = miRegistry.getProviderById("ollama"); if (ollama) { providerInfo = { provider: ollama, providerModel: modelId }; } } if (!providerInfo) { return c.json({ error: `Model "${modelId}" not available` }, 503); } // ── Build system prompt ── const moduleList = getModuleInfoList() .map((m) => `- **${m.name}** (${m.id}): ${m.icon} ${m.description}`) .join("\n"); // Extended context from client let contextSection = `- Space: ${space || "none selected"}\n- Active rApp: ${currentModule || "none"}`; if (context.pageTitle) contextSection += `\n- Page: ${context.pageTitle}`; if (context.activeTab) contextSection += `\n- Active tab: ${context.activeTab}`; if (context.openShapes?.length) { const shapeSummary = context.openShapes .slice(0, 15) .map((s: any) => { let desc = ` - ${s.type} (id: ${s.id})`; if (s.title) desc += `: ${s.title}`; if (s.snippet) desc += ` — "${s.snippet}"`; if (s.x != null) desc += ` at (${s.x}, ${s.y})`; return desc; }) .join("\n"); contextSection += `\n- Open shapes on canvas:\n${shapeSummary}`; } if (context.selectedShapes?.length) { const selSummary = context.selectedShapes .map((s: any) => ` - ${s.type} (id: ${s.id})${s.title ? `: ${s.title}` : ""}${s.snippet ? ` — "${s.snippet}"` : ""}`) .join("\n"); contextSection += `\n- The user currently has selected:\n${selSummary}`; } if (context.connections?.length) { const connSummary = context.connections .slice(0, 15) .map((conn: any) => ` - ${conn.sourceId} → ${conn.targetId}`) .join("\n"); contextSection += `\n- Connected shapes:\n${connSummary}`; } if (context.viewport) { contextSection += `\n- Viewport: zoom ${context.viewport.scale?.toFixed?.(2) || context.viewport.scale}, pan (${Math.round(context.viewport.x)}, ${Math.round(context.viewport.y)})`; } if (context.shapeGroups?.length) { contextSection += `\n- ${context.shapeGroups.length} group(s) of connected shapes`; } if (context.shapeCountByType && Object.keys(context.shapeCountByType).length) { const typeCounts = Object.entries(context.shapeCountByType) .map(([t, n]) => `${t}: ${n}`) .join(", "); contextSection += `\n- Shape types: ${typeCounts}`; } // Module capabilities for enabled modules const enabledModuleIds = Object.keys(MODULE_ROUTES); const moduleCapabilities = buildModuleCapabilities(enabledModuleIds); // Role-permission mapping const rolePermissions: Record = { viewer: "browse, explain, navigate only", member: "create content, shapes, connections", moderator: "+ configure modules, moderate content, delete content", admin: "+ enable/disable modules, manage members", }; const systemPrompt = `You are mi (mycelial intelligence), the intelligent assistant for rSpace — a self-hosted, community-run platform. You help users navigate, understand, and get the most out of the platform's apps (rApps). You understand the full context of what the user has open and can guide them through setup and usage. ## Your Caller's Role: ${callerRole} in space "${space || "none"}" - viewer: ${rolePermissions.viewer} - member: ${rolePermissions.member} - moderator: ${rolePermissions.moderator} - admin: ${rolePermissions.admin} Only suggest actions the user's role permits. The caller is a **${callerRole}**. ## Available rApps ${moduleList} ## Module Capabilities (content you can create via actions) ${moduleCapabilities} ## Current Context ${contextSection} ## Guidelines - Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail. - When suggesting actions, reference specific rApps by name and explain how they connect. - You can suggest navigating to /:space/:moduleId paths. - If the user has shapes open on their canvas, you can reference them by id and suggest connections. - Help with setup: guide users through creating spaces, adding content, configuring rApps. - If you don't know something specific about the user's data, say so honestly. - Use a warm, knowledgeable tone. You're a mycelial guide, connecting knowledge across the platform. ## Canvas Shape Actions When the user asks you to create, modify, delete, connect, move, or arrange shapes on the canvas, include action markers in your response. Each marker is on its own line: [MI_ACTION:{"type":"create-shape","tagName":"folk-markdown","props":{"content":"# Hello"},"ref":"$1"}] [MI_ACTION:{"type":"connect","sourceId":"$1","targetId":"shape-123"}] [MI_ACTION:{"type":"update-shape","shapeId":"shape-123","fields":{"content":"Updated text"}}] [MI_ACTION:{"type":"delete-shape","shapeId":"shape-123"}] [MI_ACTION:{"type":"move-shape","shapeId":"shape-123","x":400,"y":200}] [MI_ACTION:{"type":"navigate","path":"/myspace/canvas"}] Use "$1", "$2", etc. as ref values when creating shapes, then reference them in subsequent connect actions. Available shape types: folk-markdown, folk-wrapper, folk-image-gen, folk-video-gen, folk-prompt, folk-embed, folk-calendar, folk-map, folk-chat, folk-slide, folk-obs-note, folk-workflow-block, folk-social-post, folk-splat, folk-drawfast, folk-rapp, folk-feed. ## Transforms When the user asks to align, distribute, or arrange selected shapes: [MI_ACTION:{"type":"transform","transform":"align-left","shapeIds":["shape-1","shape-2"]}] Available transforms: align-left, align-right, align-center-h, align-top, align-bottom, align-center-v, distribute-h, distribute-v, arrange-row, arrange-column, arrange-grid, arrange-circle, match-width, match-height, match-size. ## Module Content Actions When the user asks to create content in a specific rApp (not a canvas shape): [MI_ACTION:{"type":"create-content","module":"rcal","contentType":"event","body":{"title":"Friday Standup","start_time":"2026-03-13T10:00:00Z","end_time":"2026-03-13T10:30:00Z"},"ref":"$1"}] [MI_ACTION:{"type":"create-content","module":"rtasks","contentType":"task","body":{"title":"Review docs","status":"TODO","priority":"high"}}] [MI_ACTION:{"type":"create-content","module":"rnotes","contentType":"notebook","body":{"title":"Meeting Notes"}}] ## Scaffolding (for complex setup — member+ only) For multi-step setup requests like "set up this space for a book club": [MI_ACTION:{"type":"scaffold","name":"Book Club Setup","steps":[...ordered actions...]}] ## Batch Actions [MI_ACTION:{"type":"batch","actions":[...actions...],"requireConfirm":true}] Use requireConfirm:true for destructive batches.`; // Build conversation const miMessages: MiMessage[] = [ { role: "system", content: systemPrompt }, ...messages.slice(-8).map((m: any) => ({ role: m.role as "user" | "assistant", content: m.content })), { role: "user", content: query }, ]; try { const gen = providerInfo.provider.stream(miMessages, providerInfo.providerModel); const body = miRegistry.streamToNDJSON(gen); return new Response(body, { headers: { "Content-Type": "application/x-ndjson", "Cache-Control": "no-cache", "Transfer-Encoding": "chunked", }, }); } catch (e: any) { console.error("mi: Provider error:", e.message); const fallback = generateFallbackResponse(query, currentModule, space, getModuleInfoList()); return c.json({ response: fallback }); } }); // ── POST /triage — content triage via Gemini ── const TRIAGE_SYSTEM_PROMPT = `You are a content triage engine for rSpace, a spatial canvas platform. Given raw unstructured content (pasted text, meeting notes, link dumps, etc.), analyze it and classify each distinct piece into the most appropriate canvas shape type. ## Shape Mapping Rules - Image URLs (.png, .jpg, .gif, .webp, .svg) → folk-image (set src prop) - Simple links / URLs (not embeddable video/interactive) → folk-bookmark (set url prop) - Embeddable URLs (YouTube, Twitter, Google Maps, Gather, etc.) → folk-embed (set url prop) - Dates / events / schedules → folk-calendar (set title, description props) - Locations / addresses / places → folk-map (set query prop) - Action items / TODOs / tasks → folk-workflow-block (set label, blockType:"action" props) - Social media content / posts → folk-social-post (set content prop) - Decisions / polls / questions for voting → folk-choice-vote (set question prop) - Everything else (prose, notes, transcripts, summaries) → folk-markdown (set content prop in markdown format) ## Output Format Return a JSON object with: - "summary": one-sentence overview of the content dump - "shapes": array of { "tagName": string, "label": string, "props": object, "snippet": string (first ~80 chars of source content) } - "connections": array of { "fromIndex": number, "toIndex": number, "reason": string } for semantic links between shapes ## Rules - Maximum 10 shapes per triage - Each shape must have a unique "label" (short, descriptive title) - props must match the shape's expected attributes - For folk-markdown content, format nicely with headers and bullet points - For folk-embed, extract the exact URL into props.url - Identify connections between related items (e.g., a note references an action item, a URL is the source for a summary) - If the content is too short or trivial for multiple shapes, still return at least one shape`; const KNOWN_TRIAGE_SHAPES = new Set([ "folk-markdown", "folk-embed", "folk-image", "folk-bookmark", "folk-calendar", "folk-map", "folk-workflow-block", "folk-social-post", "folk-choice-vote", "folk-prompt", "folk-image-gen", "folk-slide", ]); function sanitizeTriageResponse(raw: any): { shapes: any[]; connections: any[]; summary: string } { const summary = typeof raw.summary === "string" ? raw.summary : "Content analyzed"; let shapes = Array.isArray(raw.shapes) ? raw.shapes : []; let connections = Array.isArray(raw.connections) ? raw.connections : []; shapes = shapes.slice(0, 10).filter((s: any) => { if (!s.tagName || typeof s.tagName !== "string") return false; if (!KNOWN_TRIAGE_SHAPES.has(s.tagName)) { s.tagName = "folk-markdown"; } if (!s.label) s.label = "Untitled"; if (!s.props || typeof s.props !== "object") s.props = {}; if (!s.snippet) s.snippet = ""; return true; }); connections = connections.filter((conn: any) => { return ( typeof conn.fromIndex === "number" && typeof conn.toIndex === "number" && conn.fromIndex >= 0 && conn.fromIndex < shapes.length && conn.toIndex >= 0 && conn.toIndex < shapes.length && conn.fromIndex !== conn.toIndex ); }); return { shapes, connections, summary }; } mi.post("/triage", async (c) => { const { content, contentType = "paste" } = await c.req.json(); if (!content || typeof content !== "string") { return c.json({ error: "content required" }, 400); } const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ""; if (!GEMINI_API_KEY) { return c.json({ error: "GEMINI_API_KEY not configured" }, 503); } const truncated = content.length > 50000; const trimmed = truncated ? content.slice(0, 50000) : content; try { const { GoogleGenerativeAI } = await import("@google/generative-ai"); const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash", generationConfig: { responseMimeType: "application/json", } as any, }); const userPrompt = `Analyze the following ${contentType === "drop" ? "dropped" : "pasted"} content and classify each piece into canvas shapes:\n\n---\n${trimmed}\n---${truncated ? "\n\n(Content was truncated at 50k characters)" : ""}`; const result = await model.generateContent({ contents: [{ role: "user", parts: [{ text: userPrompt }] }], systemInstruction: { role: "user", parts: [{ text: TRIAGE_SYSTEM_PROMPT }] }, }); const text = result.response.text(); const parsed = JSON.parse(text); const sanitized = sanitizeTriageResponse(parsed); return c.json(sanitized); } catch (e: any) { console.error("[mi/triage] Error:", e.message); return c.json({ error: "Triage analysis failed" }, 502); } }); // ── POST /validate-actions — permission check ── function getRequiredRole(action: MiAction): SpaceRoleString { switch (action.type) { case "enable-module": case "disable-module": case "configure-module": return "admin"; case "delete-shape": case "delete-content": return "moderator"; case "create-shape": case "create-content": case "update-shape": case "update-content": case "connect": case "move-shape": case "transform": case "scaffold": case "batch": return "member"; case "navigate": return "viewer"; default: return "member"; } } mi.post("/validate-actions", async (c) => { const { actions, space } = await c.req.json() as { actions: MiAction[]; space: string }; if (!actions?.length) return c.json({ validated: [] }); // Resolve caller role let callerRole: SpaceRoleString = "viewer"; try { const token = extractToken(c.req.raw.headers); if (token) { const claims = await verifyEncryptIDToken(token); if (space && claims) { const resolved = await resolveCallerRole(space, claims); if (resolved) callerRole = resolved.role; } else if (claims) { callerRole = "member"; } } } catch { /* viewer */ } const validated = actions.map((action) => { const requiredRole = getRequiredRole(action); const allowed = roleAtLeast(callerRole, requiredRole); return { action, allowed, requiredRole }; }); return c.json({ validated, callerRole }); }); // ── Fallback response (when AI is unavailable) ── function generateFallbackResponse( query: string, currentModule: string, space: string, modules: ReturnType, ): string { const q = query.toLowerCase(); for (const m of modules) { if (q.includes(m.id) || q.includes(m.name.toLowerCase())) { return `**${m.name}** ${m.icon} — ${m.description}. You can access it at /${space || "personal"}/${m.id}.`; } } if (q.includes("help") || q.includes("what can")) { return `rSpace has ${modules.length} apps you can use. Some popular ones: **rSpace** (spatial canvas), **rNotes** (notes), **rChat** (messaging), **rFlows** (community funding), and **rVote** (governance). What would you like to explore?`; } if (q.includes("search") || q.includes("find")) { return `You can browse your content through the app switcher (top-left dropdown), or navigate directly to any rApp. Try **rNotes** for text content, **rFiles** for documents, or **rPhotos** for images.`; } return `I'm currently running in offline mode (AI service not connected). I can still help with basic navigation — ask me about any specific rApp or feature! There are ${modules.length} apps available in rSpace.`; } export { mi as miRoutes };