diff --git a/apps/cli/package.json b/apps/cli/package.json index 99af540..18f9250 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.13.0", + "version": "1.14.0", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/me.ts b/apps/cli/src/commands/me.ts index cca3f6f..a54c2b7 100644 --- a/apps/cli/src/commands/me.ts +++ b/apps/cli/src/commands/me.ts @@ -374,6 +374,129 @@ export async function runMeActivity(flags: MeActivityFlags): Promise { ); } +interface WorkspaceSearchTopicHit { + id: string; + name: string; + description: string | null; + meshId: string; + meshSlug: string; + meshName: string; +} + +interface WorkspaceSearchMessageHit { + messageId: string; + topicId: string; + topicName: string; + meshId: string; + meshSlug: string; + senderName: string; + snippet: string | null; + bodyVersion: number; + createdAt: string; +} + +interface WorkspaceSearchResponse { + query: string; + topics: WorkspaceSearchTopicHit[]; + messages: WorkspaceSearchMessageHit[]; + totals: { topics: number; messages: number }; +} + +export interface MeSearchFlags extends MeFlags { + query: string; +} + +export async function runMeSearch(flags: MeSearchFlags): Promise { + if (!flags.query || flags.query.length < 2) { + process.stderr.write( + "Usage: claudemesh me search (min 2 chars)\n", + ); + return EXIT.INVALID_ARGS; + } + + return withRestKey( + { + meshSlug: flags.mesh ?? null, + purpose: "workspace-search", + capabilities: ["read"], + }, + async ({ secret }) => { + const params = new URLSearchParams({ q: flags.query }); + const ws = await request({ + path: `/api/v1/me/search?${params.toString()}`, + token: secret, + }); + + if (flags.json) { + console.log(JSON.stringify(ws, null, 2)); + return EXIT.SUCCESS; + } + + render.section( + `${clay("search")} — "${flags.query}" ${dim( + `${ws.totals.topics} topic${ws.totals.topics === 1 ? "" : "s"}, ` + + `${ws.totals.messages} message${ws.totals.messages === 1 ? "" : "s"}`, + )}`, + ); + + if (ws.topics.length === 0 && ws.messages.length === 0) { + process.stdout.write(dim(" no matches\n")); + return EXIT.SUCCESS; + } + + if (ws.topics.length > 0) { + process.stdout.write(dim("\n topics\n")); + const slugWidth = Math.max( + ...ws.topics.map((t) => t.meshSlug.length), + 6, + ); + for (const t of ws.topics) { + const slug = dim(t.meshSlug.padEnd(slugWidth)); + const name = cyan(`#${t.name}`); + const desc = t.description ? dim(` — ${t.description}`) : ""; + process.stdout.write(` ${slug} ${name}${desc}\n`); + } + } + + if (ws.messages.length > 0) { + process.stdout.write(dim("\n messages\n")); + const slugWidth = Math.max( + ...ws.messages.map((m) => m.meshSlug.length), + 6, + ); + for (const m of ws.messages) { + const slug = dim(m.meshSlug.padEnd(slugWidth)); + const topic = cyan(`#${m.topicName}`); + const sender = m.senderName; + const ago = formatRelativeTime(m.createdAt); + const snippet = + m.snippet ?? + (m.bodyVersion === 2 ? dim("[encrypted — open the topic to decrypt]") : dim("[empty]")); + const highlighted = + m.snippet + ? highlightMatch(snippet, flags.query) + : snippet; + process.stdout.write( + ` ${slug} ${topic} ${dim(sender + " ·")} ${dim(ago)}\n` + + ` ${highlighted}\n`, + ); + } + } + return EXIT.SUCCESS; + }, + ); +} + +function highlightMatch(text: string, query: string): string { + if (!query) return text; + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return text; + const before = text.slice(0, idx); + const match = text.slice(idx, idx + query.length); + const after = text.slice(idx + query.length); + return `${before}${yellow(match)}${after}`; +} + function formatRelativeTime(iso: string): string { const then = new Date(iso).getTime(); const now = Date.now(); diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index 48f4f57..72af506 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -127,6 +127,7 @@ Topic (conversation scope, v0.2.0) claudemesh me topics cross-mesh topic list [--unread] claudemesh me notifications cross-mesh @-mentions [--all] [--since=ISO] claudemesh me activity cross-mesh recent messages [--since=ISO] + claudemesh me search cross-mesh search (topics + messages) claudemesh member list mesh roster with online state [--online] claudemesh notification list recent @-mentions of you [--since ] @@ -706,6 +707,10 @@ async function main(): Promise { since: flags.since as string | undefined, }), ); + } else if (sub === "search") { + const { runMeSearch } = await import("~/commands/me.js"); + const query = positionals.slice(1).join(" ").trim(); + process.exit(await runMeSearch({ ...f, query })); } else { console.error( "Usage: claudemesh me (cross-mesh overview)\n" + @@ -715,7 +720,8 @@ async function main(): Promise { " claudemesh me notifications --all (include already-read)\n" + " claudemesh me notifications --since=ISO (custom window)\n" + " claudemesh me activity (recent messages, last 24h)\n" + - " claudemesh me activity --since=ISO (custom window)", + " claudemesh me activity --since=ISO (custom window)\n" + + " claudemesh me search (cross-mesh search)", ); process.exit(EXIT.INVALID_ARGS); } diff --git a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx index 606d825..dcc5ed2 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx @@ -40,6 +40,11 @@ const menu = [ href: pathsConfig.dashboard.user.activity, icon: Icons.Activity, }, + { + title: "search", + href: pathsConfig.dashboard.user.search, + icon: Icons.Search, + }, { title: "invites", href: pathsConfig.dashboard.user.invites, diff --git a/apps/web/src/app/[locale]/dashboard/(user)/search/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/search/page.tsx new file mode 100644 index 0000000..4a8fd71 --- /dev/null +++ b/apps/web/src/app/[locale]/dashboard/(user)/search/page.tsx @@ -0,0 +1,319 @@ +import Link from "next/link"; + +import { db } from "@turbostarter/db/server"; +import { + mesh, + meshMember, + meshTopic, + meshTopicMessage, +} from "@turbostarter/db/schema/mesh"; +import { aliasedTable, and, asc, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm"; + +import { pathsConfig } from "~/config/paths"; +import { getSession } from "~/lib/auth/server"; +import { getMetadata } from "~/lib/metadata"; +import { Reveal } from "~/modules/dashboard/universe/reveal"; + +export const generateMetadata = getMetadata({ + title: "Search", + description: "Find topics, messages, and people across every mesh.", +}); + +const formatRelative = (iso: string) => { + const sec = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000)); + if (sec < 60) return `${sec}s ago`; + if (sec < 3600) return `${Math.floor(sec / 60)}m ago`; + if (sec < 86_400) return `${Math.floor(sec / 3600)}h ago`; + if (sec < 86_400 * 30) return `${Math.floor(sec / 86_400)}d ago`; + if (sec < 86_400 * 365) return `${Math.floor(sec / (86_400 * 30))}mo ago`; + return `${Math.floor(sec / (86_400 * 365))}y ago`; +}; + +const decode = (b64: string) => { + try { + return Buffer.from(b64, "base64").toString("utf-8"); + } catch { + return ""; + } +}; + +const Highlight = ({ text, query }: { text: string; query: string }) => { + if (!query) return <>{text}; + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return <>{text}; + return ( + <> + {text.slice(0, idx)} + + {text.slice(idx, idx + query.length)} + + {text.slice(idx + query.length)} + + ); +}; + +interface PageProps { + searchParams: Promise<{ q?: string }>; +} + +export default async function WorkspaceSearchPage({ searchParams }: PageProps) { + const { user } = await getSession(); + if (!user) return null; + + const params = await searchParams; + const q = (params.q ?? "").trim(); + + const memberships = await db + .select({ memberId: meshMember.id, meshId: meshMember.meshId }) + .from(meshMember) + .innerJoin(mesh, eq(mesh.id, meshMember.meshId)) + .where( + and( + eq(meshMember.userId, user.id), + isNull(meshMember.revokedAt), + isNull(mesh.archivedAt), + ), + ); + + const meshIds = memberships.map((m) => m.meshId); + + let topicHits: Array<{ + id: string; + name: string; + description: string | null; + meshId: string; + meshSlug: string; + }> = []; + let messageHits: Array<{ + messageId: string; + topicId: string; + topicName: string; + meshId: string; + meshSlug: string; + senderName: string; + snippet: string | null; + encrypted: boolean; + createdAt: string; + }> = []; + + if (q.length >= 2 && meshIds.length > 0) { + const pattern = `%${q.toLowerCase()}%`; + topicHits = await db + .select({ + id: meshTopic.id, + name: meshTopic.name, + description: meshTopic.description, + meshId: meshTopic.meshId, + meshSlug: mesh.slug, + }) + .from(meshTopic) + .innerJoin(mesh, eq(mesh.id, meshTopic.meshId)) + .where( + and( + inArray(meshTopic.meshId, meshIds), + isNull(meshTopic.archivedAt), + sql`lower(${meshTopic.name}) like ${pattern}`, + ), + ) + .orderBy(asc(meshTopic.name)) + .limit(50); + + const senderMember = aliasedTable(meshMember, "sender_member"); + const messageWindow = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const candidates = await db + .select({ + messageId: meshTopicMessage.id, + topicId: meshTopicMessage.topicId, + topicName: meshTopic.name, + meshId: meshTopic.meshId, + meshSlug: mesh.slug, + senderName: senderMember.displayName, + ciphertext: meshTopicMessage.ciphertext, + bodyVersion: meshTopicMessage.bodyVersion, + createdAt: meshTopicMessage.createdAt, + }) + .from(meshTopicMessage) + .innerJoin(meshTopic, eq(meshTopic.id, meshTopicMessage.topicId)) + .innerJoin(mesh, eq(mesh.id, meshTopic.meshId)) + .leftJoin(senderMember, eq(senderMember.id, meshTopicMessage.senderMemberId)) + .where( + and( + inArray(meshTopic.meshId, meshIds), + isNull(meshTopic.archivedAt), + gt(meshTopicMessage.createdAt, messageWindow), + ), + ) + .orderBy(desc(meshTopicMessage.createdAt)) + .limit(2000); + + const qLower = q.toLowerCase(); + for (const r of candidates) { + const sender = r.senderName ?? "?"; + const snippet = r.bodyVersion === 1 ? decode(r.ciphertext).slice(0, 240) : null; + const matched = + (snippet && snippet.toLowerCase().includes(qLower)) || + sender.toLowerCase().includes(qLower) || + r.topicName.toLowerCase().includes(qLower); + if (!matched) continue; + messageHits.push({ + messageId: r.messageId, + topicId: r.topicId, + topicName: r.topicName, + meshId: r.meshId, + meshSlug: r.meshSlug, + senderName: sender, + snippet, + encrypted: r.bodyVersion === 2, + createdAt: r.createdAt.toISOString(), + }); + if (messageHits.length >= 50) break; + } + } + + return ( +
+
+
+
+ +

+ Find{" "} + anything. +

+
+ + +
+ + +
+ {q && q.length < 2 ? ( +

Type at least 2 characters.

+ ) : null} + {q && q.length >= 2 ? ( +

+ {topicHits.length}topics · + {messageHits.length}messages + · 30-day window for messages +

+ ) : null} +
+
+ + {q.length < 2 ? ( +

+ Search across every mesh you belong to. Topic names, sender display names, and message text (v1 messages decoded; v2 ciphertext matched only by topic + sender). +

+ ) : topicHits.length === 0 && messageHits.length === 0 ? ( +

+ No matches for "{q}". +

+ ) : ( +
+ {topicHits.length > 0 ? ( +
+

+ Topics +

+
    + {topicHits.map((t) => ( +
  • + + + {t.meshSlug} + + + # + + {t.description ? ( + + — {t.description} + + ) : null} + +
  • + ))} +
+
+ ) : null} + + {messageHits.length > 0 ? ( +
+

+ Messages +

+
    + {messageHits.map((m) => ( +
  • + +
    + {m.meshSlug} + + # + + + from + + {formatRelative(m.createdAt)} +
    +

    + {m.encrypted ? ( + + (encrypted — open the topic to decrypt) + + ) : m.snippet ? ( + + ) : ( + (empty) + )} +

    + +
  • + ))} +
+
+ ) : null} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/config/paths.ts b/apps/web/src/config/paths.ts index d211299..ace394d 100644 --- a/apps/web/src/config/paths.ts +++ b/apps/web/src/config/paths.ts @@ -106,6 +106,7 @@ const pathsConfig = { topics: `${DASHBOARD_PREFIX}/topics`, notifications: `${DASHBOARD_PREFIX}/notifications`, activity: `${DASHBOARD_PREFIX}/activity`, + search: `${DASHBOARD_PREFIX}/search`, invites: `${DASHBOARD_PREFIX}/invites`, settings: { index: `${DASHBOARD_PREFIX}/settings`, diff --git a/docs/roadmap.md b/docs/roadmap.md index 7a55d9a..589685c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -273,12 +273,23 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code. consecutive messages from the same topic into thread blocks with sender + relative timestamp. *Shipped 2026-05-03 in CLI v1.13.0.* -- **v0.4.0 phase 5 — `me search`** — final aggregating verb. - Cross-mesh full-text search across decrypted (v1) snippets + - topic names + sender names + memory entries. Default - aggregation rule for existing read verbs (`task list`, `state - list`, `memory recall`) when no `--mesh` is passed lands here - too. +- **v0.4.0 phase 5 — `claudemesh me search` + dashboard parity** + — final aggregating verb. `GET /v1/me/search?q=...&limit=N` + matches against topic names + sender display names + v1 + message snippets (server-side base64 decode + ILIKE). v2 + messages match only on topic/sender (server doesn't hold their + topic keys). 30-day window for messages keeps the scan + bounded. CLI verb yellow-highlights matches inline; web + `/dashboard/search` adds a focused search input + `` + highlighting + 30-day scan note. *Shipped 2026-05-03 in CLI + v1.14.0.* v0.4.0 substrate is complete — every aggregating + read verb now has CLI + web parity. +- **v0.5.0 — default-aggregation rule** for existing per-mesh + read verbs (`task list`, `state list`, `memory recall`, + `notification list`, `topic list`) — when no `--mesh` is + passed, route through the `/v1/me/*` aggregator instead of + prompting for a mesh. Backward-compatible: `--mesh foo` still + scopes to one mesh. - **v0.3.2 — multi-session DM routing + broadcast self-loopback** — fixes two production bugs: (1) replies via `claudemesh send ` rejected with "no connected peer" when the sender's diff --git a/packages/api/src/modules/mesh/v1-router.ts b/packages/api/src/modules/mesh/v1-router.ts index 0edfdbd..492bfa5 100644 --- a/packages/api/src/modules/mesh/v1-router.ts +++ b/packages/api/src/modules/mesh/v1-router.ts @@ -731,6 +731,180 @@ export const v1Router = new Hono() }); }) + // GET /v1/me/search?q=... — cross-mesh full-text search. + // + // Matches against: + // - topic names (every mesh the caller belongs to) + // - sender display names (whose messages match) + // - v1 message snippets (decoded base64 plaintext, ILIKE) + // v2 messages can only match by topic name / sender name — + // the server doesn't hold their topic keys. Limit 50 per + // category. Empty query returns empty arrays without an error + // so the dashboard can render the page on first load. + .get("/me/search", async (c) => { + const key = c.var.apiKey; + requireCapability(key, "read"); + if (!key.issuedByMemberId) { + return c.json({ error: "api_key_has_no_issuer" }, 400); + } + const q = (c.req.query("q") ?? "").trim(); + const limit = Math.min( + Math.max(parseInt(c.req.query("limit") ?? "50", 10) || 50, 1), + 200, + ); + + const [issuer] = await db + .select({ userId: meshMember.userId }) + .from(meshMember) + .where(eq(meshMember.id, key.issuedByMemberId)); + if (!issuer?.userId) { + return c.json({ error: "issuer_member_has_no_user" }, 400); + } + + if (q.length < 2) { + return c.json({ + query: q, + topics: [], + messages: [], + totals: { topics: 0, messages: 0 }, + }); + } + + const memberships = await db + .select({ memberId: meshMember.id, meshId: meshMember.meshId }) + .from(meshMember) + .innerJoin(mesh, eq(mesh.id, meshMember.meshId)) + .where( + and( + eq(meshMember.userId, issuer.userId), + isNull(meshMember.revokedAt), + isNull(mesh.archivedAt), + ), + ); + + if (memberships.length === 0) { + return c.json({ + query: q, + topics: [], + messages: [], + totals: { topics: 0, messages: 0 }, + }); + } + + const meshIds = memberships.map((m) => m.meshId); + const pattern = `%${q.toLowerCase()}%`; + + const topicHits = await db + .select({ + id: meshTopic.id, + name: meshTopic.name, + description: meshTopic.description, + meshId: meshTopic.meshId, + meshSlug: mesh.slug, + meshName: mesh.name, + }) + .from(meshTopic) + .innerJoin(mesh, eq(mesh.id, meshTopic.meshId)) + .where( + and( + inArray(meshTopic.meshId, meshIds), + isNull(meshTopic.archivedAt), + sql`lower(${meshTopic.name}) like ${pattern}`, + ), + ) + .orderBy(asc(meshTopic.name)) + .limit(limit); + + // For message search we pull a wider window of recent messages + // and filter by ILIKE against the base64 ciphertext OR the + // decoded plaintext (for v1). PG can't decode base64 in a + // pattern match cheaply, so we fetch + filter in JS. 30-day + // window keeps the scan bounded. + const senderMember = aliasedTable(meshMember, "sender_member"); + const messageWindow = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const candidates = await db + .select({ + messageId: meshTopicMessage.id, + topicId: meshTopicMessage.topicId, + topicName: meshTopic.name, + meshId: meshTopic.meshId, + meshSlug: mesh.slug, + senderName: senderMember.displayName, + ciphertext: meshTopicMessage.ciphertext, + bodyVersion: meshTopicMessage.bodyVersion, + createdAt: meshTopicMessage.createdAt, + }) + .from(meshTopicMessage) + .innerJoin(meshTopic, eq(meshTopic.id, meshTopicMessage.topicId)) + .innerJoin(mesh, eq(mesh.id, meshTopic.meshId)) + .leftJoin( + senderMember, + eq(senderMember.id, meshTopicMessage.senderMemberId), + ) + .where( + and( + inArray(meshTopic.meshId, meshIds), + isNull(meshTopic.archivedAt), + gt(meshTopicMessage.createdAt, messageWindow), + ), + ) + .orderBy(desc(meshTopicMessage.createdAt)) + .limit(2000); + + const decode = (b64: string) => { + try { + return Buffer.from(b64, "base64").toString("utf-8"); + } catch { + return ""; + } + }; + + const qLower = q.toLowerCase(); + const messages: Array<{ + messageId: string; + topicId: string; + topicName: string; + meshId: string; + meshSlug: string; + senderName: string; + snippet: string | null; + bodyVersion: number; + createdAt: string; + }> = []; + for (const r of candidates) { + const senderName = r.senderName ?? "?"; + const snippet = + r.bodyVersion === 1 ? decode(r.ciphertext).slice(0, 240) : null; + const matched = + (snippet && snippet.toLowerCase().includes(qLower)) || + senderName.toLowerCase().includes(qLower) || + r.topicName.toLowerCase().includes(qLower); + if (!matched) continue; + messages.push({ + messageId: r.messageId, + topicId: r.topicId, + topicName: r.topicName, + meshId: r.meshId, + meshSlug: r.meshSlug, + senderName, + snippet, + bodyVersion: r.bodyVersion, + createdAt: r.createdAt.toISOString(), + }); + if (messages.length >= limit) break; + } + + return c.json({ + query: q, + topics: topicHits, + messages, + totals: { + topics: topicHits.length, + messages: messages.length, + }, + }); + }) + // GET /v1/me/topics — cross-mesh topic list for the caller's user. // // For each topic across every mesh the user belongs to, returns