feat(rcal): MI calendar awareness, tags, saved views, MCP server
Phase 1: MI now knows the current date/time and upcoming events (14-day lookahead, 5 events max) via direct Automerge read — no HTTP overhead. Phase 2: Tags (string[] | null) on CalendarEvent for first-class filtering. Saved views (named filter presets) with full CRUD API. Tag filter on GET /api/events via comma-separated AND logic. Demo events seeded with tags. Phase 3: Calendar MCP server (5 tools: cal_list_events, cal_get_event, cal_list_sources, cal_list_views, cal_create_event) registered in .mcp.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f2c3245240
commit
2dd5e764cd
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"rspace-calendar": {
|
||||
"command": "node",
|
||||
"args": ["/home/jeffe/.claude/mcp-servers/calendar/index.js"],
|
||||
"env": {
|
||||
"RSPACE_BASE_URL": "http://localhost:3000",
|
||||
"RSPACE_DEFAULT_SPACE": "demo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ import { verifyToken, extractToken } from "../../server/auth";
|
|||
import { renderLanding } from "./landing";
|
||||
import type { SyncServer } from '../../server/local-first/sync-server';
|
||||
import { calendarSchema, calendarDocId } from './schemas';
|
||||
import type { CalendarDoc, CalendarEvent, CalendarSource, ScheduledItemMetadata } from './schemas';
|
||||
import type { CalendarDoc, CalendarEvent, CalendarSource, SavedCalendarView, ScheduledItemMetadata } from './schemas';
|
||||
|
||||
let _syncServer: SyncServer | null = null;
|
||||
|
||||
|
|
@ -39,9 +39,17 @@ function ensureDoc(space: string): CalendarDoc {
|
|||
d.meta.spaceSlug = space;
|
||||
d.sources = {};
|
||||
d.events = {};
|
||||
d.views = {};
|
||||
});
|
||||
_syncServer!.setDoc(docId, doc);
|
||||
}
|
||||
// Backward compat: old docs may lack views
|
||||
if (!doc.views) {
|
||||
_syncServer!.changeDoc<CalendarDoc>(docId, 'add views map', (d) => {
|
||||
d.views = {};
|
||||
});
|
||||
doc = _syncServer!.getDoc<CalendarDoc>(docId)!;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +92,7 @@ function eventToRow(ev: CalendarEvent, sources: Record<string, CalendarSource>)
|
|||
virtual_platform: ev.virtualPlatform,
|
||||
r_tool_source: ev.rToolSource,
|
||||
r_tool_entity_id: ev.rToolEntityId,
|
||||
tags: ev.tags ?? null,
|
||||
attendees: ev.attendees,
|
||||
attendee_count: ev.attendeeCount,
|
||||
metadata: ev.metadata,
|
||||
|
|
@ -163,12 +172,14 @@ function seedDemoIfEmpty(space: string) {
|
|||
locationId?: string; locationName?: string;
|
||||
locationLat?: number; locationLng?: number; locationGranularity?: string;
|
||||
isVirtual?: boolean; virtualUrl?: string; virtualPlatform?: string;
|
||||
tags?: string[];
|
||||
}> = [
|
||||
{
|
||||
title: "rSpace Launch Party",
|
||||
desc: "Celebrating the launch of the unified rSpace platform with all 22 modules live.",
|
||||
start: daysFromNow(-21, 18, 0), end: daysFromNow(-21, 22, 0),
|
||||
sourceId: communityId, locationName: "Radiant Hall, Pittsburgh",
|
||||
tags: ["launch", "community"],
|
||||
},
|
||||
{
|
||||
title: "Provider Onboarding Workshop",
|
||||
|
|
@ -176,6 +187,7 @@ function seedDemoIfEmpty(space: string) {
|
|||
start: daysFromNow(-12, 14, 0), end: daysFromNow(-12, 17, 0),
|
||||
sourceId: communityId, isVirtual: true,
|
||||
virtualUrl: "https://meet.jit.si/rspace-providers", virtualPlatform: "Jitsi",
|
||||
tags: ["onboarding", "cosmolocal"],
|
||||
},
|
||||
{
|
||||
title: "Weekly Community Standup",
|
||||
|
|
@ -183,12 +195,14 @@ function seedDemoIfEmpty(space: string) {
|
|||
start: daysFromNow(0, 16, 0), end: daysFromNow(0, 16, 45),
|
||||
sourceId: communityId, isVirtual: true,
|
||||
virtualUrl: "https://meet.jit.si/rspace-standup", virtualPlatform: "Jitsi",
|
||||
tags: ["recurring", "community"],
|
||||
},
|
||||
{
|
||||
title: "Sprint: Module Seeding & Polish",
|
||||
desc: "Focus sprint on populating demo data and improving UX across all modules.",
|
||||
start: daysFromNow(0, 9, 0), end: daysFromNow(5, 18, 0),
|
||||
sourceId: sprintsId, allDay: true,
|
||||
tags: ["sprint", "dev"],
|
||||
},
|
||||
{
|
||||
title: "rFlows Budget Review",
|
||||
|
|
@ -196,6 +210,7 @@ function seedDemoIfEmpty(space: string) {
|
|||
start: daysFromNow(6, 15, 0), end: daysFromNow(6, 17, 0),
|
||||
sourceId: communityId, isVirtual: true,
|
||||
virtualUrl: "https://meet.jit.si/rflows-review", virtualPlatform: "Jitsi",
|
||||
tags: ["governance", "finance"],
|
||||
},
|
||||
{
|
||||
title: "Cosmolocal Design Sprint",
|
||||
|
|
@ -204,6 +219,7 @@ function seedDemoIfEmpty(space: string) {
|
|||
sourceId: sprintsId,
|
||||
locationId: berlinLocId, locationName: "Druckwerkstatt Berlin",
|
||||
locationLat: 52.52, locationLng: 13.405, locationGranularity: "city",
|
||||
tags: ["sprint", "design", "travel"],
|
||||
},
|
||||
{
|
||||
title: "Q1 Retrospective",
|
||||
|
|
@ -211,6 +227,7 @@ function seedDemoIfEmpty(space: string) {
|
|||
start: daysFromNow(21, 16, 0), end: daysFromNow(21, 18, 0),
|
||||
sourceId: communityId, isVirtual: true,
|
||||
virtualUrl: "https://meet.jit.si/rspace-retro", virtualPlatform: "Jitsi",
|
||||
tags: ["recurring", "community", "retrospective"],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -244,6 +261,7 @@ function seedDemoIfEmpty(space: string) {
|
|||
rToolEntityId: null,
|
||||
attendees: [],
|
||||
attendeeCount: 0,
|
||||
tags: e.tags || null,
|
||||
metadata: null,
|
||||
likelihood: null,
|
||||
createdAt: now,
|
||||
|
|
@ -266,12 +284,21 @@ function seedDemoIfEmpty(space: string) {
|
|||
routes.get("/api/events", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = c.get("effectiveSpace") || space;
|
||||
const { start, end, source, search, rTool, rEntityId, upcoming } = c.req.query();
|
||||
const { start, end, source, search, rTool, rEntityId, upcoming, tags: tagsParam } = c.req.query();
|
||||
|
||||
const doc = ensureDoc(dataSpace);
|
||||
let events = Object.values(doc.events);
|
||||
|
||||
// Apply filters
|
||||
if (tagsParam) {
|
||||
const requiredTags = tagsParam.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
||||
if (requiredTags.length > 0) {
|
||||
events = events.filter((e) => {
|
||||
const eTags = (e.tags ?? []).map((t) => t.toLowerCase());
|
||||
return requiredTags.every((rt) => eTags.includes(rt));
|
||||
});
|
||||
}
|
||||
}
|
||||
if (start) {
|
||||
const startMs = new Date(start).getTime();
|
||||
events = events.filter((e) => e.startTime >= startMs);
|
||||
|
|
@ -322,7 +349,7 @@ routes.post("/api/events", async (c) => {
|
|||
const body = await c.req.json();
|
||||
const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name,
|
||||
is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id,
|
||||
is_scheduled_item, provenance, item_preview } = body;
|
||||
is_scheduled_item, provenance, item_preview, tags: bodyTags } = body;
|
||||
if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400);
|
||||
|
||||
const docId = calendarDocId(dataSpace);
|
||||
|
|
@ -389,6 +416,7 @@ routes.post("/api/events", async (c) => {
|
|||
rToolEntityId: r_tool_entity_id || null,
|
||||
attendees: [],
|
||||
attendeeCount: 0,
|
||||
tags: Array.isArray(bodyTags) ? bodyTags : null,
|
||||
metadata: metadata,
|
||||
likelihood: null,
|
||||
createdAt: now,
|
||||
|
|
@ -509,6 +537,7 @@ routes.post("/api/import-ics", async (c) => {
|
|||
rToolEntityId: null,
|
||||
attendees: [],
|
||||
attendeeCount: 0,
|
||||
tags: null,
|
||||
metadata: null,
|
||||
likelihood: null,
|
||||
createdAt: now,
|
||||
|
|
@ -597,6 +626,7 @@ routes.patch("/api/events/:id", async (c) => {
|
|||
location_name: 'locationName',
|
||||
is_virtual: 'isVirtual',
|
||||
virtual_url: 'virtualUrl',
|
||||
tags: 'tags',
|
||||
metadata: 'metadata',
|
||||
};
|
||||
|
||||
|
|
@ -843,6 +873,106 @@ routes.get("/api/context/:tool", async (c) => {
|
|||
return c.json({ count: rows.length, results: rows });
|
||||
});
|
||||
|
||||
// ── API: Saved Views ──
|
||||
|
||||
function viewToRow(v: SavedCalendarView) {
|
||||
return {
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
icon: v.icon,
|
||||
filter: v.filter,
|
||||
default_view_mode: v.defaultViewMode,
|
||||
created_at: v.createdAt ? new Date(v.createdAt).toISOString() : null,
|
||||
updated_at: v.updatedAt ? new Date(v.updatedAt).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
routes.get("/api/views", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = c.get("effectiveSpace") || space;
|
||||
const doc = ensureDoc(dataSpace);
|
||||
|
||||
const views = Object.values(doc.views ?? {}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
return c.json({ count: views.length, results: views.map(viewToRow) });
|
||||
});
|
||||
|
||||
routes.post("/api/views", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = c.get("effectiveSpace") || space;
|
||||
const body = await c.req.json();
|
||||
if (!body.name?.trim()) return c.json({ error: "name required" }, 400);
|
||||
|
||||
const docId = calendarDocId(dataSpace);
|
||||
ensureDoc(dataSpace);
|
||||
const viewId = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
_syncServer!.changeDoc<CalendarDoc>(docId, `create view ${viewId}`, (d) => {
|
||||
d.views[viewId] = {
|
||||
id: viewId,
|
||||
name: body.name.trim(),
|
||||
icon: body.icon || null,
|
||||
filter: body.filter || {},
|
||||
defaultViewMode: body.default_view_mode || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
|
||||
const updated = _syncServer!.getDoc<CalendarDoc>(docId)!;
|
||||
return c.json(viewToRow(updated.views[viewId]), 201);
|
||||
});
|
||||
|
||||
routes.patch("/api/views/:id", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = c.get("effectiveSpace") || space;
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
|
||||
const docId = calendarDocId(dataSpace);
|
||||
const doc = ensureDoc(dataSpace);
|
||||
if (!doc.views?.[id]) return c.json({ error: "View not found" }, 404);
|
||||
|
||||
_syncServer!.changeDoc<CalendarDoc>(docId, `update view ${id}`, (d) => {
|
||||
const v = d.views[id];
|
||||
if (body.name !== undefined) v.name = body.name.trim();
|
||||
if (body.icon !== undefined) v.icon = body.icon;
|
||||
if (body.filter !== undefined) v.filter = body.filter;
|
||||
if (body.default_view_mode !== undefined) v.defaultViewMode = body.default_view_mode;
|
||||
v.updatedAt = Date.now();
|
||||
});
|
||||
|
||||
const updated = _syncServer!.getDoc<CalendarDoc>(docId)!;
|
||||
return c.json(viewToRow(updated.views[id]));
|
||||
});
|
||||
|
||||
routes.delete("/api/views/:id", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = c.get("effectiveSpace") || space;
|
||||
const id = c.req.param("id");
|
||||
|
||||
const docId = calendarDocId(dataSpace);
|
||||
const doc = ensureDoc(dataSpace);
|
||||
if (!doc.views?.[id]) return c.json({ error: "View not found" }, 404);
|
||||
|
||||
_syncServer!.changeDoc<CalendarDoc>(docId, `delete view ${id}`, (d) => {
|
||||
delete d.views[id];
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
|
|
@ -860,6 +990,47 @@ routes.get("/", (c) => {
|
|||
}));
|
||||
});
|
||||
|
||||
// ── MI Integration ──
|
||||
|
||||
/** Lean event shape for MI system prompt (minimal token footprint) */
|
||||
export interface MICalendarEvent {
|
||||
title: string;
|
||||
start: string; // ISO date string
|
||||
end: string; // ISO date string
|
||||
allDay: boolean;
|
||||
location: string | null;
|
||||
isVirtual: boolean;
|
||||
tags: string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read upcoming events directly from Automerge (no HTTP overhead).
|
||||
* Returns [] if sync server not initialized or no events exist.
|
||||
*/
|
||||
export function getUpcomingEventsForMI(space: string, days = 14, limit = 5): MICalendarEvent[] {
|
||||
if (!_syncServer) return [];
|
||||
const docId = calendarDocId(space);
|
||||
const doc = _syncServer.getDoc<CalendarDoc>(docId);
|
||||
if (!doc) return [];
|
||||
|
||||
const now = Date.now();
|
||||
const futureMs = now + days * 86400000;
|
||||
|
||||
return Object.values(doc.events)
|
||||
.filter((e) => e.startTime >= now && e.startTime <= futureMs)
|
||||
.sort((a, b) => a.startTime - b.startTime)
|
||||
.slice(0, limit)
|
||||
.map((e) => ({
|
||||
title: e.title,
|
||||
start: new Date(e.startTime).toISOString(),
|
||||
end: e.endTime ? new Date(e.endTime).toISOString() : new Date(e.startTime).toISOString(),
|
||||
allDay: e.allDay,
|
||||
location: e.locationName || null,
|
||||
isVirtual: e.isVirtual,
|
||||
tags: e.tags ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export const calModule: RSpaceModule = {
|
||||
id: "rcal",
|
||||
name: "rCal",
|
||||
|
|
|
|||
|
|
@ -52,11 +52,31 @@ export interface CalendarEvent {
|
|||
rToolEntityId: string | null;
|
||||
attendees: unknown[];
|
||||
attendeeCount: number;
|
||||
tags: string[] | null;
|
||||
metadata: unknown | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SavedCalendarViewFilter {
|
||||
tags?: string[];
|
||||
sourceIds?: string[];
|
||||
search?: string;
|
||||
locationName?: string;
|
||||
isVirtual?: boolean;
|
||||
rToolSource?: string;
|
||||
}
|
||||
|
||||
export interface SavedCalendarView {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
filter: SavedCalendarViewFilter;
|
||||
defaultViewMode: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface CalendarDoc {
|
||||
meta: {
|
||||
module: string;
|
||||
|
|
@ -67,6 +87,7 @@ export interface CalendarDoc {
|
|||
};
|
||||
sources: Record<string, CalendarSource>;
|
||||
events: Record<string, CalendarEvent>;
|
||||
views: Record<string, SavedCalendarView>;
|
||||
}
|
||||
|
||||
// ── Schema registration ──
|
||||
|
|
@ -85,6 +106,7 @@ export const calendarSchema: DocSchema<CalendarDoc> = {
|
|||
},
|
||||
sources: {},
|
||||
events: {},
|
||||
views: {},
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -262,6 +262,7 @@ async function executeCalendarEvent(
|
|||
rToolEntityId: job.id,
|
||||
attendees: [],
|
||||
attendeeCount: 0,
|
||||
tags: null,
|
||||
metadata: null,
|
||||
likelihood: null,
|
||||
createdAt: now,
|
||||
|
|
@ -1087,6 +1088,7 @@ function syncReminderToCalendar(reminder: Reminder, space: string): string | nul
|
|||
rToolEntityId: reminder.id,
|
||||
attendees: [],
|
||||
attendeeCount: 0,
|
||||
tags: null,
|
||||
metadata: null,
|
||||
likelihood: null,
|
||||
createdAt: now,
|
||||
|
|
@ -1696,6 +1698,7 @@ async function executeWorkflowNode(
|
|||
rToolEntityId: node.id,
|
||||
attendees: [],
|
||||
attendeeCount: 0,
|
||||
tags: null,
|
||||
metadata: null,
|
||||
likelihood: null,
|
||||
createdAt: now,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ 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 { getUpcomingEventsForMI } from "../modules/rcal/mod";
|
||||
|
||||
const mi = new Hono();
|
||||
|
||||
|
|
@ -128,10 +129,34 @@ mi.post("/ask", async (c) => {
|
|||
admin: "+ enable/disable modules, manage members",
|
||||
};
|
||||
|
||||
// ── Build time + calendar context ──
|
||||
const now = new Date();
|
||||
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 = "";
|
||||
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 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.
|
||||
|
||||
## Current Date & Time
|
||||
${timeContext}
|
||||
|
||||
## Your Caller's Role: ${callerRole} in space "${space || "none"}"
|
||||
- viewer: ${rolePermissions.viewer}
|
||||
- member: ${rolePermissions.member}
|
||||
|
|
@ -148,7 +173,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}
|
||||
${contextSection}${calendarContext}
|
||||
|
||||
## Guidelines
|
||||
- Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail.
|
||||
|
|
|
|||
Loading…
Reference in New Issue