From 75fd5cf4be522d71835772709120dfb60ae4aecc Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 12 Apr 2026 22:19:07 -0400 Subject: [PATCH] feat(mi): per-space knowledge index with ranked context injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the 265-line data dump (35 modules × 3 items) in MI system prompts with a trigram-ranked knowledge index that surfaces only the top-18 most relevant entries per query. Adds per-space conversation memory persisted to disk for cross-session context. New files: - server/mi-trigrams.ts — trigram + Jaccard similarity utilities - server/space-knowledge.ts — SpaceKnowledgeIndex with 5-min TTL cache - server/space-memory.ts — SpaceMemory with debounced disk persistence Changes: - mi-routes.ts: ~280 lines removed, replaced with ranked index call - sync-instance.ts: cache invalidation on doc changes - rauctions/mod.ts: fix ModuleScoping type (defaultScope, userConfigurable) - mcp-tools/ragents.ts: fix AccessResult property access (claims.username, claims.sub) ~80% token reduction per MI request (~6,300 → ~1,320 tokens). Co-Authored-By: Claude Opus 4.6 --- modules/rauctions/mod.ts | 2 +- server/mcp-tools/ragents.ts | 10 +- server/mi-routes.ts | 312 +------------------ server/mi-trigrams.ts | 25 ++ server/space-knowledge.ts | 581 ++++++++++++++++++++++++++++++++++++ server/space-memory.ts | 151 ++++++++++ server/sync-instance.ts | 3 + 7 files changed, 780 insertions(+), 304 deletions(-) create mode 100644 server/mi-trigrams.ts create mode 100644 server/space-knowledge.ts create mode 100644 server/space-memory.ts diff --git a/modules/rauctions/mod.ts b/modules/rauctions/mod.ts index d35fe44a..840a6609 100644 --- a/modules/rauctions/mod.ts +++ b/modules/rauctions/mod.ts @@ -16,5 +16,5 @@ export const auctionsModule: RSpaceModule = { icon: '🏛', description: 'Community auctions with USDC', routes, - scoping: { scope: 'space', default: 'disabled' }, + scoping: { defaultScope: 'space', userConfigurable: true }, }; diff --git a/server/mcp-tools/ragents.ts b/server/mcp-tools/ragents.ts index 4c0cfd73..6b321760 100644 --- a/server/mcp-tools/ragents.ts +++ b/server/mcp-tools/ragents.ts @@ -112,12 +112,12 @@ export function registerAgentsTools(server: McpServer, syncServer: SyncServer) { const id = crypto.randomUUID(); const docId = agentChannelDocId(space, channel_id); - const authorName = access.username || "Agent"; + const authorName = access.claims?.username || "Agent"; syncServer.changeDoc(docId, `agent post ${id}`, (d) => { if (!d.posts) (d as any).posts = {}; d.posts[id] = { - id, channelId: channel_id, authorAgentId: access.did || '', authorName, + id, channelId: channel_id, authorAgentId: access.claims?.sub || '', authorName, content, payload: payload || null, replyTo: null, votes: {}, createdAt: Date.now(), updatedAt: Date.now(), } as any; @@ -149,11 +149,11 @@ export function registerAgentsTools(server: McpServer, syncServer: SyncServer) { const id = crypto.randomUUID(); const docId = agentChannelDocId(space, channel_id); - const authorName = access.username || "Agent"; + const authorName = access.claims?.username || "Agent"; syncServer.changeDoc(docId, `reply to ${post_id}`, (d) => { d.posts[id] = { - id, channelId: channel_id, authorAgentId: access.did || '', authorName, + id, channelId: channel_id, authorAgentId: access.claims?.sub || '', authorName, content, payload: payload || null, replyTo: post_id, votes: {}, createdAt: Date.now(), updatedAt: Date.now(), } as any; @@ -182,7 +182,7 @@ export function registerAgentsTools(server: McpServer, syncServer: SyncServer) { return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Post not found" }) }] }; } - const voterId = access.did || "anon"; + const voterId = access.claims?.sub || "anon"; const docId = agentChannelDocId(space, channel_id); syncServer.changeDoc(docId, `vote on ${post_id}`, (d) => { diff --git a/server/mi-routes.ts b/server/mi-routes.ts index b4013c86..7918b092 100644 --- a/server/mi-routes.ts +++ b/server/mi-routes.ts @@ -18,42 +18,14 @@ import { verifyToken, extractToken } from "./auth"; import type { EncryptIDClaims } from "./auth"; import { buildModuleCapabilities, MODULE_ROUTES } from "../lib/mi-module-routes"; import type { MiAction } from "../lib/mi-actions"; +import { spaceKnowledgeIndex } from "./space-knowledge"; +import { spaceMemory, streamWithMemoryCapture } from "./space-memory"; +import { runAgenticLoop } from "./mi-agent"; +// Module imports retained for /suggestions endpoint import { getUpcomingEventsForMI } from "../modules/rcal/mod"; import { getRecentVaultNotesForMI } from "../modules/rnotes/mod"; import { getRecentTasksForMI } from "../modules/rtasks/mod"; -import { getRecentCampaignsForMI } from "../modules/rsocials/mod"; -import { getRecentContactsForMI } from "../modules/rnetwork/mod"; -import { getRecentThreadsForMI } from "../modules/rinbox/mod"; -import { getRecentCommitmentsForMI } from "../modules/rtime/mod"; -import { getRecentFilesForMI } from "../modules/rfiles/mod"; -import { getUpcomingRemindersForMI } from "../modules/rschedule/mod"; -import { getMapPinsForMI } from "../modules/rmaps/mod"; -import { getRecentMeetingsForMI } from "../modules/rmeets/mod"; -import { getRecentVideosForMI } from "../modules/rtube/mod"; -import { getRecentMessagesForMI } from "../modules/rchats/mod"; -import { getRecentPublicationsForMI } from "../modules/rpubs/mod"; -import { getRecentDesignsForMI } from "../modules/rswag/mod"; -import { getRecentSheetsForMI } from "../modules/rsheets/mod"; import { getRecentDocsForMI } from "../modules/rdocs/mod"; -import { getRecentSessionsForMI } from "../modules/rdesign/mod"; -import { getSharedAlbumsForMI } from "../modules/rphotos/mod"; -import { getRecentFlowsForMI } from "../modules/rflows/mod"; -import { getRecentIntentsForMI } from "../modules/rexchange/mod"; -import { getRecentOrdersForMI } from "../modules/rcart/mod"; -import { getActiveProposalsForMI } from "../modules/rvote/mod"; -import { getRecentBooksForMI } from "../modules/rbooks/mod"; -import { getRecentSplatsForMI } from "../modules/rsplat/mod"; -import { getRecentTripsForMI } from "../modules/rtrips/mod"; -import { getActiveListingsForMI } from "../modules/rbnb/mod"; -import { getActiveVehiclesForMI } from "../modules/rvnb/mod"; -import { getForumInstancesForMI } from "../modules/rforum/mod"; -import { getRecentChoiceSessionsForMI } from "../modules/rchoices/mod"; -import { getActivePromptsForMI } from "../modules/crowdsurf/mod"; -import { getGovShapesForMI } from "../modules/rgov/mod"; -import { getCrdtTokensForMI } from "../modules/rwallet/mod"; -import { getCanvasSummaryForMI } from "../modules/rspace/mod"; -import { getDataSummaryForMI } from "../modules/rdata/mod"; -import { runAgenticLoop } from "./mi-agent"; import { generateImage, generateVideoViaFal } from "./mi-media"; import { queryModuleContent } from "./mi-data-queries"; @@ -186,271 +158,12 @@ mi.post("/ask", async (c) => { const weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const timeContext = `${weekdays[now.getUTCDay()]}, ${now.toISOString().split("T")[0]} at ${now.toISOString().split("T")[1].split(".")[0]} UTC`; - let calendarContext = ""; - let notesContext = ""; - let tasksContext = ""; - let campaignsContext = ""; - let contactsContext = ""; - let inboxContext = ""; - let commitmentsContext = ""; - let filesContext = ""; - let remindersContext = ""; - let mapsContext = ""; - let meetsContext = ""; - let tubeContext = ""; - let chatsContext = ""; - let pubsContext = ""; - let swagContext = ""; - let sheetsContext = ""; - let docsContext = ""; - let designContext = ""; - let photosContext = ""; - let flowsContext = ""; - let exchangeContext = ""; - let cartContext = ""; - let voteContext = ""; - let booksContext = ""; - let splatsContext = ""; - let tripsContext = ""; - let bnbContext = ""; - let vnbContext = ""; - let forumContext = ""; - let choicesContext = ""; - let crowdsurfContext = ""; - let govContext = ""; - let walletContext = ""; - let canvasContext = ""; + // ── Build ranked knowledge context + conversation memory ── + let rankedKnowledgeContext = ""; + let conversationMemoryContext = ""; if (space) { - const upcoming = getUpcomingEventsForMI(space); - if (upcoming.length > 0) { - const lines = upcoming.map((e) => { - const date = e.allDay ? e.start.split("T")[0] : e.start; - let line = `- ${date}: ${e.title}`; - if (e.location) line += ` (${e.location})`; - else if (e.isVirtual) line += ` (virtual)`; - if (e.tags?.length) line += ` [${e.tags.join(", ")}]`; - return line; - }); - calendarContext = `\n- Upcoming events (next 14 days):\n${lines.join("\n")}`; - } - - const vaultNotes = getRecentVaultNotesForMI(space, 3); - if (vaultNotes.length > 0) { - const lines = vaultNotes.map((n) => - `- "${n.title}" in ${n.vaultName} (${n.path})${n.tags.length ? ` [${n.tags.join(", ")}]` : ""}` - ); - notesContext = `\n- Recent vault notes:\n${lines.join("\n")}`; - } - - const openTasks = getRecentTasksForMI(space, 5); - if (openTasks.length > 0) { - const lines = openTasks.map((t) => - `- "${t.title}" [${t.status}]${t.priority ? ` (${t.priority})` : ""}${t.description ? `: ${t.description.slice(0, 80)}` : ""}` - ); - tasksContext = `\n- Open tasks:\n${lines.join("\n")}`; - } - - const recentCampaigns = getRecentCampaignsForMI(space, 3); - if (recentCampaigns.length > 0) { - const lines = recentCampaigns.map((c) => - `- "${c.title}" (${c.platforms.join(", ")}, ${c.postCount} posts)` - ); - campaignsContext = `\n- Recent campaigns:\n${lines.join("\n")}`; - } - - const contacts = getRecentContactsForMI(space, 3); - if (contacts.length > 0) { - const lines = contacts.map((c) => - `- ${c.name} (${c.role})${c.tags.length ? ` [${c.tags.join(", ")}]` : ""}` - ); - contactsContext = `\n- Network contacts:\n${lines.join("\n")}`; - } - - const recentThreads = getRecentThreadsForMI(space, 3); - if (recentThreads.length > 0) { - const lines = recentThreads.map((t) => - `- "${t.subject}" from ${t.fromAddress || "unknown"} [${t.status}]${t.isRead ? "" : " (unread)"}` - ); - inboxContext = `\n- Recent emails:\n${lines.join("\n")}`; - } - - const commitments = getRecentCommitmentsForMI(space, 3); - if (commitments.length > 0) { - const lines = commitments.map((c) => - `- ${c.memberName}: ${c.hours}h ${c.skill} — ${c.desc.slice(0, 80)}` - ); - commitmentsContext = `\n- Active commitments:\n${lines.join("\n")}`; - } - - const recentFiles = getRecentFilesForMI(space, 3); - if (recentFiles.length > 0) { - const lines = recentFiles.map((f) => - `- ${f.title || f.originalFilename} (${f.mimeType || "unknown"})` - ); - filesContext = `\n- Recent files:\n${lines.join("\n")}`; - } - - const reminders = getUpcomingRemindersForMI(space, 14, 3); - if (reminders.length > 0) { - const lines = reminders.map((r) => { - const date = new Date(r.remindAt).toISOString().split("T")[0]; - return `- ${date}: ${r.title}${r.sourceModule ? ` (from ${r.sourceModule})` : ""}`; - }); - remindersContext = `\n- Upcoming reminders:\n${lines.join("\n")}`; - } - - const pins = getMapPinsForMI(space, 5); - if (pins.length > 0) { - const lines = pins.map((p) => `- "${p.label}" (${p.type}) at ${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}`); - mapsContext = `\n- Map pins:\n${lines.join("\n")}`; - } - - const meetings = getRecentMeetingsForMI(space, 3); - if (meetings.length > 0) { - const lines = meetings.map((m) => `- "${m.title}" (${m.participantCount} participants)`); - meetsContext = `\n- Recent meetings:\n${lines.join("\n")}`; - } - - const playlists = getRecentVideosForMI(space, 3); - if (playlists.length > 0) { - const lines = playlists.map((v) => `- "${v.name}" (${v.entryCount} entries)`); - tubeContext = `\n- Playlists:\n${lines.join("\n")}`; - } - - const chatMsgs = getRecentMessagesForMI(space, 3); - if (chatMsgs.length > 0) { - const lines = chatMsgs.map((m) => `- [${m.channel}] ${m.author}: ${m.content.slice(0, 80)}`); - chatsContext = `\n- Recent chats:\n${lines.join("\n")}`; - } - - const pubs = getRecentPublicationsForMI(space, 3); - if (pubs.length > 0) { - const lines = pubs.map((p) => `- "${p.title}" by ${p.author} (${p.format})`); - pubsContext = `\n- Publications:\n${lines.join("\n")}`; - } - - const swagDesigns = getRecentDesignsForMI(space, 3); - if (swagDesigns.length > 0) { - const lines = swagDesigns.map((d) => `- "${d.title}" (${d.productType}, ${d.status})`); - swagContext = `\n- Store designs:\n${lines.join("\n")}`; - } - - const sheets = getRecentSheetsForMI(space, 3); - if (sheets.length > 0) { - const lines = sheets.map((s) => `- "${s.name}" (${s.cellCount} cells)`); - sheetsContext = `\n- Spreadsheets:\n${lines.join("\n")}`; - } - - const recentDocs = getRecentDocsForMI(space, 3); - if (recentDocs.length > 0) { - const lines = recentDocs.map((n) => - `- "${n.title}" (${n.type}${n.tags.length ? `, tags: ${n.tags.join(", ")}` : ""}): ${n.contentPlain.slice(0, 100)}…` - ); - docsContext = `\n- Recent docs:\n${lines.join("\n")}`; - } - - const designSessions = getRecentSessionsForMI(space, 3); - if (designSessions.length > 0) { - const lines = designSessions.map((s) => `- "${s.title}" (${s.pageCount} pages, ${s.frameCount} frames)`); - designContext = `\n- Design sessions:\n${lines.join("\n")}`; - } - - const albums = getSharedAlbumsForMI(space, 3); - if (albums.length > 0) { - const lines = albums.map((a) => `- "${a.name}"`); - photosContext = `\n- Shared albums:\n${lines.join("\n")}`; - } - - const flows = getRecentFlowsForMI(space, 3); - if (flows.length > 0) { - const lines = flows.map((f) => `- "${f.name}" (${f.nodeCount} nodes)`); - flowsContext = `\n- Flows:\n${lines.join("\n")}`; - } - - const intents = getRecentIntentsForMI(space, 3); - if (intents.length > 0) { - const lines = intents.map((i) => `- ${i.side} ${i.tokenId} [${i.status}]`); - exchangeContext = `\n- Exchange intents:\n${lines.join("\n")}`; - } - - const orders = getRecentOrdersForMI(space, 3); - if (orders.length > 0) { - const lines = orders.map((o) => `- "${o.title}" [${o.status}]`); - cartContext = `\n- Recent orders:\n${lines.join("\n")}`; - } - - const proposals = getActiveProposalsForMI(space, 3); - if (proposals.length > 0) { - const lines = proposals.map((p) => `- "${p.title}" [${p.status}] (${p.voteCount} votes)`); - voteContext = `\n- Proposals:\n${lines.join("\n")}`; - } - - const books = getRecentBooksForMI(space, 3); - if (books.length > 0) { - const lines = books.map((b) => `- "${b.title}" by ${b.author}`); - booksContext = `\n- Books:\n${lines.join("\n")}`; - } - - const splats = getRecentSplatsForMI(space, 3); - if (splats.length > 0) { - const lines = splats.map((s) => `- "${s.title}" (${s.format})`); - splatsContext = `\n- 3D scenes:\n${lines.join("\n")}`; - } - - const trips = getRecentTripsForMI(space, 3); - if (trips.length > 0) { - const lines = trips.map((t) => `- "${t.title}" [${t.status}] (${t.destinationCount} destinations)`); - tripsContext = `\n- Trips:\n${lines.join("\n")}`; - } - - const bnbListings = getActiveListingsForMI(space, 3); - if (bnbListings.length > 0) { - const lines = bnbListings.map((l) => `- "${l.title}" (${l.type}, ${l.economy})`); - bnbContext = `\n- BnB listings:\n${lines.join("\n")}`; - } - - const vnbVehicles = getActiveVehiclesForMI(space, 3); - if (vnbVehicles.length > 0) { - const lines = vnbVehicles.map((v) => `- "${v.title}" (${v.type}, ${v.economy})`); - vnbContext = `\n- Vehicles:\n${lines.join("\n")}`; - } - - const forumInstances = getForumInstancesForMI(space, 3); - if (forumInstances.length > 0) { - const lines = forumInstances.map((i) => `- "${i.name}" (${i.domain || "pending"}) [${i.status}]`); - forumContext = `\n- Forum instances:\n${lines.join("\n")}`; - } - - const choiceSessions = getRecentChoiceSessionsForMI(space, 3); - if (choiceSessions.length > 0) { - const lines = choiceSessions.map((s) => `- "${s.title}" (${s.type}, ${s.optionCount} options)`); - choicesContext = `\n- Choice sessions:\n${lines.join("\n")}`; - } - - const csPrompts = getActivePromptsForMI(space, 3); - if (csPrompts.length > 0) { - const lines = csPrompts.map((p) => `- "${p.text.slice(0, 60)}" (${p.swipeCount}/${p.threshold})`); - crowdsurfContext = `\n- Crowdsurf prompts:\n${lines.join("\n")}`; - } - - const govShapes = getGovShapesForMI(space); - if (govShapes.length > 0) { - const lines = govShapes.map((s) => `- ${s.type}: ${s.count}`); - govContext = `\n- Governance shapes:\n${lines.join("\n")}`; - } - - const crdtTokens = getCrdtTokensForMI(space, 3); - if (crdtTokens.length > 0) { - const lines = crdtTokens.map((t) => `- ${t.symbol} (${t.name}): supply ${t.totalSupply}`); - walletContext = `\n- CRDT tokens:\n${lines.join("\n")}`; - } - - const canvasSummary = getCanvasSummaryForMI(space); - if (canvasSummary.length > 0 && canvasSummary[0].totalShapes > 0) { - const s = canvasSummary[0]; - const top = s.typeBreakdown.slice(0, 5).map((t) => `${t.type}: ${t.count}`).join(", "); - canvasContext = `\n- Canvas: ${s.totalShapes} shapes (${top})`; - } + rankedKnowledgeContext = spaceKnowledgeIndex.getRankedContext(space, query, 18); + conversationMemoryContext = await spaceMemory.getRelevantTurns(space, query, 3); } const systemPrompt = `You are mi (mycelial intelligence), the intelligent assistant for rSpace — a self-hosted, community-run platform. @@ -476,7 +189,7 @@ ${moduleCapabilities} When the user asks to create a social media campaign, use create-content with module rsocials, contentType campaign, body.rawBrief set to the user's description, body.navigateToWizard true. ## Current Context -${contextSection}${calendarContext}${notesContext}${tasksContext}${campaignsContext}${contactsContext}${inboxContext}${commitmentsContext}${filesContext}${remindersContext}${mapsContext}${meetsContext}${tubeContext}${chatsContext}${pubsContext}${swagContext}${sheetsContext}${docsContext}${designContext}${photosContext}${flowsContext}${exchangeContext}${cartContext}${voteContext}${booksContext}${splatsContext}${tripsContext}${bnbContext}${vnbContext}${forumContext}${choicesContext}${crowdsurfContext}${govContext}${walletContext}${canvasContext} +${contextSection}${rankedKnowledgeContext}${conversationMemoryContext} ## Guidelines - Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail. @@ -596,7 +309,10 @@ Use requireConfirm:true for destructive batches.`; maxTurns: 5, }); - return new Response(body, { + // Wrap stream to capture response text for conversation memory + const responseStream = space ? streamWithMemoryCapture(body, space, query) : body; + + return new Response(responseStream, { headers: { "Content-Type": "application/x-ndjson", "Cache-Control": "no-cache", diff --git a/server/mi-trigrams.ts b/server/mi-trigrams.ts new file mode 100644 index 00000000..e694e421 --- /dev/null +++ b/server/mi-trigrams.ts @@ -0,0 +1,25 @@ +/** + * Trigram + Jaccard similarity utilities for MI knowledge ranking. + * Pure functions, zero dependencies. + */ + +/** Extract character trigrams from text (lowercased). */ +export function trigrams(text: string): Set { + const s = text.toLowerCase().replace(/[^\w\s]/g, ""); + const set = new Set(); + for (let i = 0; i <= s.length - 3; i++) { + set.add(s.slice(i, i + 3)); + } + return set; +} + +/** Jaccard similarity between two trigram sets (0..1). */ +export function jaccardSimilarity(a: Set, b: Set): number { + if (a.size === 0 && b.size === 0) return 0; + let intersection = 0; + for (const t of a) { + if (b.has(t)) intersection++; + } + const union = a.size + b.size - intersection; + return union === 0 ? 0 : intersection / union; +} diff --git a/server/space-knowledge.ts b/server/space-knowledge.ts new file mode 100644 index 00000000..30b8c9c0 --- /dev/null +++ b/server/space-knowledge.ts @@ -0,0 +1,581 @@ +/** + * Per-Space Knowledge Index — ranked retrieval for MI context injection. + * + * Instead of dumping all 35 module summaries into every prompt, + * this index builds KnowledgeEntry objects from each module and + * returns only the top-N most relevant entries for a given query + * using trigram similarity + recency boost + category weighting. + */ + +import { trigrams, jaccardSimilarity } from "./mi-trigrams"; + +import { getUpcomingEventsForMI } from "../modules/rcal/mod"; +import { getRecentVaultNotesForMI } from "../modules/rnotes/mod"; +import { getRecentTasksForMI } from "../modules/rtasks/mod"; +import { getRecentCampaignsForMI } from "../modules/rsocials/mod"; +import { getRecentContactsForMI } from "../modules/rnetwork/mod"; +import { getRecentThreadsForMI } from "../modules/rinbox/mod"; +import { getRecentCommitmentsForMI } from "../modules/rtime/mod"; +import { getRecentFilesForMI } from "../modules/rfiles/mod"; +import { getUpcomingRemindersForMI } from "../modules/rschedule/mod"; +import { getMapPinsForMI } from "../modules/rmaps/mod"; +import { getRecentMeetingsForMI } from "../modules/rmeets/mod"; +import { getRecentVideosForMI } from "../modules/rtube/mod"; +import { getRecentMessagesForMI } from "../modules/rchats/mod"; +import { getRecentAgentPostsForMI } from "../modules/ragents/mod"; +import { getRecentPublicationsForMI } from "../modules/rpubs/mod"; +import { getRecentDesignsForMI } from "../modules/rswag/mod"; +import { getRecentSheetsForMI } from "../modules/rsheets/mod"; +import { getRecentDocsForMI } from "../modules/rdocs/mod"; +import { getRecentSessionsForMI } from "../modules/rdesign/mod"; +import { getSharedAlbumsForMI } from "../modules/rphotos/mod"; +import { getRecentFlowsForMI } from "../modules/rflows/mod"; +import { getRecentIntentsForMI } from "../modules/rexchange/mod"; +import { getRecentOrdersForMI } from "../modules/rcart/mod"; +import { getActiveProposalsForMI } from "../modules/rvote/mod"; +import { getRecentBooksForMI } from "../modules/rbooks/mod"; +import { getRecentSplatsForMI } from "../modules/rsplat/mod"; +import { getRecentTripsForMI } from "../modules/rtrips/mod"; +import { getActiveListingsForMI } from "../modules/rbnb/mod"; +import { getActiveVehiclesForMI } from "../modules/rvnb/mod"; +import { getForumInstancesForMI } from "../modules/rforum/mod"; +import { getRecentChoiceSessionsForMI } from "../modules/rchoices/mod"; +import { getActivePromptsForMI } from "../modules/crowdsurf/mod"; +import { getGovShapesForMI } from "../modules/rgov/mod"; +import { getCrdtTokensForMI } from "../modules/rwallet/mod"; +import { getCanvasSummaryForMI } from "../modules/rspace/mod"; +import { getDataSummaryForMI } from "../modules/rdata/mod"; + +// ── Types ── + +type KnowledgeCategory = + | "tasks" | "time" | "content" | "people" | "social" + | "commerce" | "community" | "spatial" | "media" | "infra" | "canvas"; + +interface KnowledgeEntry { + id: string; + moduleId: string; + category: KnowledgeCategory; + title: string; + detail: string; + tags: string[]; + timestamp: number; + formatted: string; +} + +// ── Category weights (higher = more likely to surface) ── + +const CATEGORY_WEIGHT: Record = { + tasks: 1.5, + time: 1.4, + content: 1.3, + people: 1.2, + social: 1.1, + commerce: 0.9, + community: 0.8, + spatial: 0.7, + media: 0.7, + infra: 0.6, + canvas: 0.5, +}; + +const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const PER_MODULE_LIMIT = 12; + +// ── SpaceKnowledgeIndex ── + +class SpaceKnowledgeIndex { + #cache = new Map(); + + /** Get ranked context string for injection into MI system prompt. */ + getRankedContext(space: string, query: string, topN = 18): string { + const { entries } = this.#getOrBuild(space); + if (entries.length === 0) return ""; + + const ranked = this.#rank(entries, query, topN); + if (ranked.length === 0) return ""; + + // Group by module for readability + const groups = new Map(); + for (const entry of ranked) { + if (!groups.has(entry.moduleId)) groups.set(entry.moduleId, []); + groups.get(entry.moduleId)!.push(entry.formatted); + } + + const lines: string[] = []; + for (const [moduleId, items] of groups) { + lines.push(`- [${moduleId}]`); + for (const item of items) { + lines.push(` ${item}`); + } + } + + return `\n- Space data (ranked by relevance):\n${lines.join("\n")}`; + } + + /** Invalidate cache for a space (called on doc changes). */ + invalidate(space: string): void { + this.#cache.delete(space); + } + + #getOrBuild(space: string): { entries: KnowledgeEntry[]; builtAt: number } { + const cached = this.#cache.get(space); + if (cached && Date.now() - cached.builtAt < CACHE_TTL) return cached; + + const entries = this.#buildIndex(space); + const result = { entries, builtAt: Date.now() }; + this.#cache.set(space, result); + return result; + } + + #buildIndex(space: string): KnowledgeEntry[] { + const entries: KnowledgeEntry[] = []; + const now = Date.now(); + + // Each adapter is wrapped in try/catch so one module failure doesn't block others + + try { + for (const e of getUpcomingEventsForMI(space, 14, PER_MODULE_LIMIT)) { + const date = e.allDay ? e.start.split("T")[0] : e.start; + let line = `${date}: ${e.title}`; + if (e.location) line += ` (${e.location})`; + else if (e.isVirtual) line += ` (virtual)`; + if (e.tags?.length) line += ` [${e.tags.join(", ")}]`; + entries.push({ + id: `rcal:${e.title}:${e.start}`, moduleId: "rcal", category: "time", + title: e.title, detail: `${e.location || ""} ${(e.tags || []).join(" ")}`, + tags: e.tags || [], timestamp: Date.parse(e.start) || now, formatted: line, + }); + } + } catch {} + + try { + for (const n of getRecentVaultNotesForMI(space, PER_MODULE_LIMIT)) { + const line = `"${n.title}" in ${n.vaultName} (${n.path})${n.tags.length ? ` [${n.tags.join(", ")}]` : ""}`; + entries.push({ + id: `rnotes:${n.path}`, moduleId: "rnotes", category: "content", + title: n.title, detail: `${n.vaultName} ${n.path}`, + tags: n.tags, timestamp: now, formatted: line, + }); + } + } catch {} + + try { + for (const t of getRecentTasksForMI(space, PER_MODULE_LIMIT)) { + const line = `"${t.title}" [${t.status}]${t.priority ? ` (${t.priority})` : ""}${t.description ? `: ${t.description.slice(0, 80)}` : ""}`; + entries.push({ + id: `rtasks:${t.id}`, moduleId: "rtasks", category: "tasks", + title: t.title, detail: t.description?.slice(0, 200) || "", + tags: [t.status, t.priority].filter(Boolean) as string[], + timestamp: t.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const c of getRecentCampaignsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${c.title}" (${c.platforms.join(", ")}, ${c.postCount} posts)`; + entries.push({ + id: `rsocials:${c.id}`, moduleId: "rsocials", category: "social", + title: c.title, detail: c.platforms.join(" "), + tags: c.platforms, timestamp: c.updatedAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const c of getRecentContactsForMI(space, PER_MODULE_LIMIT)) { + const line = `${c.name} (${c.role})${c.tags.length ? ` [${c.tags.join(", ")}]` : ""}`; + entries.push({ + id: `rnetwork:${c.did}`, moduleId: "rnetwork", category: "people", + title: c.name, detail: c.role, + tags: c.tags, timestamp: now, formatted: line, + }); + } + } catch {} + + try { + for (const t of getRecentThreadsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${t.subject}" from ${t.fromAddress || "unknown"} [${t.status}]${t.isRead ? "" : " (unread)"}`; + entries.push({ + id: `rinbox:${t.subject}`, moduleId: "rinbox", category: "people", + title: t.subject, detail: t.fromAddress || "", + tags: [t.status, t.isRead ? "" : "unread"].filter(Boolean), + timestamp: t.receivedAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const c of getRecentCommitmentsForMI(space, PER_MODULE_LIMIT)) { + const line = `${c.memberName}: ${c.hours}h ${c.skill} — ${c.desc.slice(0, 80)}`; + entries.push({ + id: `rtime:${c.id}`, moduleId: "rtime", category: "people", + title: `${c.memberName} ${c.skill}`, detail: c.desc.slice(0, 200), + tags: [c.skill], timestamp: now, formatted: line, + }); + } + } catch {} + + try { + for (const f of getRecentFilesForMI(space, PER_MODULE_LIMIT)) { + const line = `${f.title || f.originalFilename} (${f.mimeType || "unknown"})`; + entries.push({ + id: `rfiles:${f.id}`, moduleId: "rfiles", category: "content", + title: f.title || f.originalFilename, detail: f.mimeType || "", + tags: [], timestamp: f.updatedAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const r of getUpcomingRemindersForMI(space, 14, PER_MODULE_LIMIT)) { + const date = new Date(r.remindAt).toISOString().split("T")[0]; + const line = `${date}: ${r.title}${r.sourceModule ? ` (from ${r.sourceModule})` : ""}`; + entries.push({ + id: `rschedule:${r.id}`, moduleId: "rschedule", category: "time", + title: r.title, detail: r.sourceModule || "", + tags: [r.sourceModule].filter(Boolean) as string[], + timestamp: r.remindAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const p of getMapPinsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${p.label}" (${p.type}) at ${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}`; + entries.push({ + id: `rmaps:${p.id}`, moduleId: "rmaps", category: "spatial", + title: p.label, detail: p.type, + tags: [p.type], timestamp: p.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const m of getRecentMeetingsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${m.title}" (${m.participantCount} participants)`; + entries.push({ + id: `rmeets:${m.id}`, moduleId: "rmeets", category: "people", + title: m.title, detail: "", + tags: [], timestamp: m.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const v of getRecentVideosForMI(space, PER_MODULE_LIMIT)) { + const line = `"${v.name}" (${v.entryCount} entries)`; + entries.push({ + id: `rtube:${v.id}`, moduleId: "rtube", category: "media", + title: v.name, detail: "", + tags: [], timestamp: v.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const m of getRecentMessagesForMI(space, PER_MODULE_LIMIT)) { + const line = `[${m.channel}] ${m.author}: ${m.content.slice(0, 80)}`; + entries.push({ + id: `rchats:${m.id}`, moduleId: "rchats", category: "social", + title: `${m.channel} ${m.author}`, detail: m.content.slice(0, 200), + tags: [m.channel], timestamp: m.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const p of getRecentAgentPostsForMI(space, PER_MODULE_LIMIT)) { + const line = `[${p.channel}] ${p.author}: ${p.content.slice(0, 80)}${p.hasPayload ? " [+data]" : ""}`; + entries.push({ + id: `ragents:${p.id}`, moduleId: "ragents", category: "social", + title: `${p.channel} ${p.author}`, detail: p.content.slice(0, 200), + tags: [p.channel], timestamp: p.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const p of getRecentPublicationsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${p.title}" by ${p.author} (${p.format})`; + entries.push({ + id: `rpubs:${p.id}`, moduleId: "rpubs", category: "content", + title: p.title, detail: `${p.author} ${p.format}`, + tags: [p.format], timestamp: p.updatedAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const d of getRecentDesignsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${d.title}" (${d.productType}, ${d.status})`; + entries.push({ + id: `rswag:${d.id}`, moduleId: "rswag", category: "media", + title: d.title, detail: `${d.productType} ${d.status}`, + tags: [d.productType, d.status], timestamp: d.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const s of getRecentSheetsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${s.name}" (${s.cellCount} cells)`; + entries.push({ + id: `rsheets:${s.id}`, moduleId: "rsheets", category: "infra", + title: s.name, detail: "", + tags: [], timestamp: s.updatedAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const n of getRecentDocsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${n.title}" (${n.type}${n.tags.length ? `, tags: ${n.tags.join(", ")}` : ""}): ${n.contentPlain.slice(0, 100)}`; + entries.push({ + id: `rdocs:${n.id}`, moduleId: "rdocs", category: "content", + title: n.title, detail: n.contentPlain.slice(0, 200), + tags: n.tags, timestamp: n.updatedAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const s of getRecentSessionsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${s.title}" (${s.pageCount} pages, ${s.frameCount} frames)`; + entries.push({ + id: `rdesign:${s.title}`, moduleId: "rdesign", category: "media", + title: s.title, detail: "", + tags: [], timestamp: now, formatted: line, + }); + } + } catch {} + + try { + for (const a of getSharedAlbumsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${a.name}"`; + entries.push({ + id: `rphotos:${a.id}`, moduleId: "rphotos", category: "media", + title: a.name, detail: "", + tags: [], timestamp: a.sharedAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const f of getRecentFlowsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${f.name}" (${f.nodeCount} nodes)`; + entries.push({ + id: `rflows:${f.id}`, moduleId: "rflows", category: "infra", + title: f.name, detail: "", + tags: [], timestamp: f.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const i of getRecentIntentsForMI(space, PER_MODULE_LIMIT)) { + const line = `${i.side} ${i.tokenId} [${i.status}]`; + entries.push({ + id: `rexchange:${i.id}`, moduleId: "rexchange", category: "commerce", + title: `${i.side} ${i.tokenId}`, detail: i.status, + tags: [i.side, i.status], timestamp: i.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const o of getRecentOrdersForMI(space, PER_MODULE_LIMIT)) { + const line = `"${o.title}" [${o.status}]`; + entries.push({ + id: `rcart:${o.id}`, moduleId: "rcart", category: "commerce", + title: o.title, detail: o.status, + tags: [o.status], timestamp: o.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const p of getActiveProposalsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${p.title}" [${p.status}] (${p.voteCount} votes)`; + entries.push({ + id: `rvote:${p.id}`, moduleId: "rvote", category: "community", + title: p.title, detail: p.status, + tags: [p.status], timestamp: p.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const b of getRecentBooksForMI(space, PER_MODULE_LIMIT)) { + const line = `"${b.title}" by ${b.author}`; + entries.push({ + id: `rbooks:${b.id}`, moduleId: "rbooks", category: "content", + title: b.title, detail: b.author, + tags: [b.author], timestamp: b.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const s of getRecentSplatsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${s.title}" (${s.format})`; + entries.push({ + id: `rsplat:${s.id}`, moduleId: "rsplat", category: "media", + title: s.title, detail: s.format, + tags: [s.format], timestamp: s.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const t of getRecentTripsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${t.title}" [${t.status}] (${t.destinationCount} destinations)`; + entries.push({ + id: `rtrips:${t.id}`, moduleId: "rtrips", category: "spatial", + title: t.title, detail: t.status, + tags: [t.status], timestamp: t.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const l of getActiveListingsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${l.title}" (${l.type}, ${l.economy})`; + entries.push({ + id: `rbnb:${l.id}`, moduleId: "rbnb", category: "infra", + title: l.title, detail: `${l.type} ${l.economy}`, + tags: [l.type, l.economy], timestamp: l.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const v of getActiveVehiclesForMI(space, PER_MODULE_LIMIT)) { + const line = `"${v.title}" (${v.type}, ${v.economy})`; + entries.push({ + id: `rvnb:${v.id}`, moduleId: "rvnb", category: "infra", + title: v.title, detail: `${v.type} ${v.economy}`, + tags: [v.type, v.economy], timestamp: v.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const i of getForumInstancesForMI(space, PER_MODULE_LIMIT)) { + const line = `"${i.name}" (${i.domain || "pending"}) [${i.status}]`; + entries.push({ + id: `rforum:${i.id}`, moduleId: "rforum", category: "community", + title: i.name, detail: i.domain || "", + tags: [i.status], timestamp: i.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const s of getRecentChoiceSessionsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${s.title}" (${s.type}, ${s.optionCount} options)`; + entries.push({ + id: `rchoices:${s.id}`, moduleId: "rchoices", category: "community", + title: s.title, detail: s.type, + tags: [s.type], timestamp: s.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const p of getActivePromptsForMI(space, PER_MODULE_LIMIT)) { + const line = `"${p.text.slice(0, 60)}" (${p.swipeCount}/${p.threshold})`; + entries.push({ + id: `crowdsurf:${p.id}`, moduleId: "crowdsurf", category: "community", + title: p.text.slice(0, 80), detail: "", + tags: [], timestamp: p.createdAt || now, formatted: line, + }); + } + } catch {} + + try { + for (const s of getGovShapesForMI(space)) { + const line = `${s.type}: ${s.count}`; + entries.push({ + id: `rgov:${s.type}`, moduleId: "rgov", category: "community", + title: s.type, detail: `${s.count} shapes`, + tags: [s.type], timestamp: now, formatted: line, + }); + } + } catch {} + + try { + for (const t of getCrdtTokensForMI(space, PER_MODULE_LIMIT)) { + const line = `${t.symbol} (${t.name}): supply ${t.totalSupply}`; + entries.push({ + id: `rwallet:${t.tokenId}`, moduleId: "rwallet", category: "commerce", + title: `${t.symbol} ${t.name}`, detail: `supply ${t.totalSupply}`, + tags: [t.symbol], timestamp: now, formatted: line, + }); + } + } catch {} + + try { + const summary = getCanvasSummaryForMI(space); + if (summary.length > 0 && summary[0].totalShapes > 0) { + const s = summary[0]; + const top = s.typeBreakdown.slice(0, 5).map((t) => `${t.type}: ${t.count}`).join(", "); + entries.push({ + id: "rspace:canvas", moduleId: "rspace", category: "canvas", + title: "Canvas summary", detail: top, + tags: s.typeBreakdown.map((t) => t.type), timestamp: now, + formatted: `${s.totalShapes} shapes (${top})`, + }); + } + } catch {} + + try { + const data = getDataSummaryForMI(space); + if (data.length > 0) { + entries.push({ + id: "rdata:summary", moduleId: "rdata", category: "infra", + title: "Analytics", detail: data.map((d) => `${d.label}: ${d.value}`).join(", "), + tags: ["analytics"], timestamp: now, + formatted: `Analytics: ${data.map((d) => `${d.label}=${d.value}`).join(", ")}`, + }); + } + } catch {} + + return entries; + } + + #rank(entries: KnowledgeEntry[], query: string, topN: number): KnowledgeEntry[] { + const now = Date.now(); + + if (!query.trim()) { + // No query — sort by recency * category weight + return entries + .map((e) => ({ + entry: e, + score: (0.5 + 0.2 * Math.exp(-(now - e.timestamp) / SEVEN_DAYS)) * CATEGORY_WEIGHT[e.category], + })) + .sort((a, b) => b.score - a.score) + .slice(0, topN) + .map((r) => r.entry); + } + + const queryTrigrams = trigrams(query); + + return entries + .map((e) => { + const text = `${e.title} ${e.detail} ${e.tags.join(" ")}`; + const entryTrigrams = trigrams(text); + const sim = jaccardSimilarity(queryTrigrams, entryTrigrams); + const recencyBoost = 0.2 * Math.exp(-(now - e.timestamp) / SEVEN_DAYS); + const score = (sim + recencyBoost) * CATEGORY_WEIGHT[e.category]; + return { entry: e, score }; + }) + .sort((a, b) => b.score - a.score) + .slice(0, topN) + .map((r) => r.entry); + } +} + +export const spaceKnowledgeIndex = new SpaceKnowledgeIndex(); diff --git a/server/space-memory.ts b/server/space-memory.ts new file mode 100644 index 00000000..cec4bb4b --- /dev/null +++ b/server/space-memory.ts @@ -0,0 +1,151 @@ +/** + * Per-Space Conversation Memory — persists MI turns to disk per space. + * + * Lets MI reference past interactions via trigram-ranked retrieval. + * Storage: /data/files/space-knowledge/{space}/memory.json + */ + +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { trigrams, jaccardSimilarity } from "./mi-trigrams"; + +interface ConversationTurn { + query: string; + response: string; // first 500 chars + timestamp: number; +} + +interface MemoryFile { + turns: ConversationTurn[]; + lastWrittenAt: string; +} + +const DATA_ROOT = "/data/files/space-knowledge"; +const MAX_TURNS = 50; +const FLUSH_DELAY = 10_000; // 10s debounce + +class SpaceMemory { + #pending = new Map(); + #timers = new Map>(); + + /** Get trigram-ranked past turns relevant to current query. */ + async getRelevantTurns(space: string, query: string, topN = 3): Promise { + const file = await this.#load(space); + if (!file || file.turns.length === 0) return ""; + + const queryTri = trigrams(query); + const scored = file.turns + .map((turn) => { + const text = `${turn.query} ${turn.response}`; + const sim = jaccardSimilarity(queryTri, trigrams(text)); + return { turn, score: sim }; + }) + .filter((s) => s.score > 0.05) + .sort((a, b) => b.score - a.score) + .slice(0, topN); + + if (scored.length === 0) return ""; + + const lines = scored.map((s) => { + const date = new Date(s.turn.timestamp).toISOString().split("T")[0]; + const shortA = s.turn.response.slice(0, 120).replace(/\n/g, " "); + return ` - [${date}] Q: ${s.turn.query.slice(0, 80)} -> A: ${shortA}`; + }); + + return `\n- Past relevant conversations:\n${lines.join("\n")}`; + } + + /** Fire-and-forget append. Debounced 10s flush to disk. */ + appendTurn(space: string, query: string, response: string): void { + const turn: ConversationTurn = { + query: query.slice(0, 300), + response: response.slice(0, 500), + timestamp: Date.now(), + }; + + if (!this.#pending.has(space)) this.#pending.set(space, []); + this.#pending.get(space)!.push(turn); + + // Debounce flush + if (this.#timers.has(space)) clearTimeout(this.#timers.get(space)!); + this.#timers.set(space, setTimeout(() => this.#flush(space), FLUSH_DELAY)); + } + + async #flush(space: string): Promise { + const pending = this.#pending.get(space); + if (!pending || pending.length === 0) return; + this.#pending.delete(space); + this.#timers.delete(space); + + try { + const file = (await this.#load(space)) || { turns: [], lastWrittenAt: "" }; + file.turns.push(...pending); + + // FIFO eviction + if (file.turns.length > MAX_TURNS) { + file.turns = file.turns.slice(-MAX_TURNS); + } + + file.lastWrittenAt = new Date().toISOString(); + + const dir = join(DATA_ROOT, space); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "memory.json"), JSON.stringify(file, null, 2)); + } catch (e: any) { + console.error(`[space-memory] flush failed for ${space}:`, e.message); + } + } + + async #load(space: string): Promise { + try { + const raw = await readFile(join(DATA_ROOT, space, "memory.json"), "utf-8"); + return JSON.parse(raw); + } catch { + return null; + } + } +} + +export const spaceMemory = new SpaceMemory(); + +/** + * Wrap a ReadableStream to capture assistant response text for memory. + * Parses NDJSON lines, accumulates content from message chunks. + */ +export function streamWithMemoryCapture( + source: ReadableStream, + space: string, + query: string, +): ReadableStream { + let accumulated = ""; + const decoder = new TextDecoder(); + + return source.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + // Pass through unchanged + controller.enqueue(chunk); + + // Try to extract text content from NDJSON lines + try { + const text = decoder.decode(chunk, { stream: true }); + for (const line of text.split("\n")) { + if (!line.trim()) continue; + const parsed = JSON.parse(line); + if (parsed.message?.role === "assistant" && parsed.message.content) { + accumulated += parsed.message.content; + } + } + } catch { + // Non-JSON chunk or partial line — ignore + } + }, + flush() { + // Stream complete — save to memory + if (accumulated.length > 10) { + spaceMemory.appendTurn(space, query, accumulated); + } + }, + }), + ); +} diff --git a/server/sync-instance.ts b/server/sync-instance.ts index 5fd2df87..4d987775 100644 --- a/server/sync-instance.ts +++ b/server/sync-instance.ts @@ -14,6 +14,7 @@ import { SyncServer } from "./local-first/sync-server"; import { saveDoc, saveEncryptedBlob, loadEncryptedBlob } from "./local-first/doc-persistence"; import { getDocumentData } from "./community-store"; +import { spaceKnowledgeIndex } from "./space-knowledge"; /** * Look up the encryption key ID for a doc's space. @@ -34,6 +35,8 @@ export const syncServer = new SyncServer({ participantMode: true, maxDocs: 500, onDocChange: (docId, doc) => { + const spaceSlug = docId.split(":")[0]; + if (spaceSlug && spaceSlug !== "global") spaceKnowledgeIndex.invalidate(spaceSlug); const encryptionKeyId = getEncryptionKeyId(docId); saveDoc(docId, doc, encryptionKeyId); },