feat(workspace): claudemesh me search + dashboard parity
ships v0.4.0 phase 5 — final aggregating verb. v0.4.0 substrate is complete after this. api: GET /v1/me/search?q=... matches against topic names + sender display names + v1 message snippets (base64 decode then ilike). v2 ciphertext matches only on topic/sender — server has no topic keys. 30-day window on messages, capped at 50 hits per category. cli (1.14.0): claudemesh me search <query> renders topic + msg sections with inline yellow highlighting. min 2 chars; --json returns the raw response. web: /dashboard/search adds an autofocused input + mark highlighting on every match site (topic name, sender, snippet). sidebar gets a search entry between activity and invites. roadmap: phase 5 marked shipped, v0.5.0 default-aggregation behavior added as the natural next track. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -374,6 +374,129 @@ export async function runMeActivity(flags: MeActivityFlags): Promise<number> {
|
||||
);
|
||||
}
|
||||
|
||||
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<number> {
|
||||
if (!flags.query || flags.query.length < 2) {
|
||||
process.stderr.write(
|
||||
"Usage: claudemesh me search <query> (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<WorkspaceSearchResponse>({
|
||||
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();
|
||||
|
||||
@@ -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 <q> cross-mesh search (topics + messages)
|
||||
claudemesh member list mesh roster with online state [--online]
|
||||
claudemesh notification list recent @-mentions of you [--since <ISO>]
|
||||
|
||||
@@ -706,6 +707,10 @@ async function main(): Promise<void> {
|
||||
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<void> {
|
||||
" 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 <query> (cross-mesh search)",
|
||||
);
|
||||
process.exit(EXIT.INVALID_ARGS);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
319
apps/web/src/app/[locale]/dashboard/(user)/search/page.tsx
Normal file
319
apps/web/src/app/[locale]/dashboard/(user)/search/page.tsx
Normal file
@@ -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)}
|
||||
<mark className="bg-[rgba(217,119,87,0.18)] px-0.5 text-[var(--cm-clay)]">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{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 (
|
||||
<div className="@container relative h-full p-6 md:p-10">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 70% 50% at 50% -10%, rgba(217,119,87,0.06), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 mx-auto max-w-[900px]">
|
||||
<header className="mb-10 border-b border-[var(--cm-border-soft,rgba(217,119,87,0.1))] pb-8 md:mb-14 md:pb-10">
|
||||
<Reveal delay={0}>
|
||||
<h1
|
||||
className="mb-6 text-[clamp(2rem,1.6rem+2.5vw,3.25rem)] leading-[1.05] tracking-tight"
|
||||
style={{ fontFamily: "var(--cm-font-serif)", fontWeight: 400 }}
|
||||
>
|
||||
<span className="italic text-[var(--cm-fg-tertiary)]">Find</span>{" "}
|
||||
<span className="italic text-[var(--cm-clay)]">anything</span>.
|
||||
</h1>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={1}>
|
||||
<form method="get" className="flex items-center gap-3">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder="topic, sender, or text…"
|
||||
autoFocus
|
||||
className="flex-1 rounded-md border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-4 py-2.5 text-[15px] text-[var(--cm-fg)] placeholder-[var(--cm-fg-tertiary)] outline-none focus:border-[var(--cm-clay)] focus:ring-1 focus:ring-[rgba(217,119,87,0.3)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border border-[var(--cm-clay)] bg-[var(--cm-clay)] px-4 py-2.5 font-mono text-[12px] uppercase tracking-[0.18em] text-white hover:opacity-90"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
{q && q.length < 2 ? (
|
||||
<p className="mt-3 text-[12px] text-[var(--cm-fg-tertiary)]">Type at least 2 characters.</p>
|
||||
) : null}
|
||||
{q && q.length >= 2 ? (
|
||||
<p className="mt-3 font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]">
|
||||
<span className="mr-2 text-[var(--cm-fg)]">{topicHits.length}</span>topics ·
|
||||
<span className="mx-2 text-[var(--cm-fg)]">{messageHits.length}</span>messages
|
||||
· 30-day window for messages
|
||||
</p>
|
||||
) : null}
|
||||
</Reveal>
|
||||
</header>
|
||||
|
||||
{q.length < 2 ? (
|
||||
<p className="text-[var(--cm-fg-secondary)]">
|
||||
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).
|
||||
</p>
|
||||
) : topicHits.length === 0 && messageHits.length === 0 ? (
|
||||
<p className="text-[var(--cm-fg-secondary)]">
|
||||
No matches for "<span className="text-[var(--cm-clay)]">{q}</span>".
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-10">
|
||||
{topicHits.length > 0 ? (
|
||||
<section>
|
||||
<h2 className="mb-3 font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]">
|
||||
Topics
|
||||
</h2>
|
||||
<ul className="flex flex-col">
|
||||
{topicHits.map((t) => (
|
||||
<li key={t.id}>
|
||||
<Link
|
||||
href={pathsConfig.dashboard.user.meshes.topic(t.meshId, t.name)}
|
||||
className="flex items-baseline gap-4 border-b border-[var(--cm-border-soft,rgba(217,119,87,0.08))] py-3 hover:bg-[var(--cm-bg-hover)]"
|
||||
>
|
||||
<span className="w-32 shrink-0 font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--cm-fg-tertiary)]">
|
||||
{t.meshSlug}
|
||||
</span>
|
||||
<span
|
||||
className="text-[18px] tracking-tight text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
#<Highlight text={t.name} query={q} />
|
||||
</span>
|
||||
{t.description ? (
|
||||
<span className="hidden truncate text-[13px] text-[var(--cm-fg-tertiary)] md:inline">
|
||||
— {t.description}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{messageHits.length > 0 ? (
|
||||
<section>
|
||||
<h2 className="mb-3 font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]">
|
||||
Messages
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{messageHits.map((m) => (
|
||||
<li
|
||||
key={m.messageId}
|
||||
className="rounded-md border border-[var(--cm-border-soft,rgba(217,119,87,0.1))] bg-[var(--cm-bg-elevated)] px-5 py-4"
|
||||
>
|
||||
<Link
|
||||
href={pathsConfig.dashboard.user.meshes.topic(m.meshId, m.topicName)}
|
||||
className="block"
|
||||
>
|
||||
<div className="mb-2 flex items-baseline gap-3 font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--cm-fg-tertiary)]">
|
||||
<span>{m.meshSlug}</span>
|
||||
<span className="text-[var(--cm-clay)]">
|
||||
#<Highlight text={m.topicName} query={q} />
|
||||
</span>
|
||||
<span>
|
||||
from <Highlight text={m.senderName} query={q} />
|
||||
</span>
|
||||
<span className="ml-auto">{formatRelative(m.createdAt)}</span>
|
||||
</div>
|
||||
<p
|
||||
className="text-[14px] leading-[1.55] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{m.encrypted ? (
|
||||
<span className="italic text-[var(--cm-fg-tertiary)]">
|
||||
(encrypted — open the topic to decrypt)
|
||||
</span>
|
||||
) : m.snippet ? (
|
||||
<Highlight text={m.snippet} query={q} />
|
||||
) : (
|
||||
<span className="italic text-[var(--cm-fg-tertiary)]">(empty)</span>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 + `<mark>`
|
||||
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
|
||||
<from_id>` rejected with "no connected peer" when the sender's
|
||||
|
||||
@@ -731,6 +731,180 @@ export const v1Router = new Hono<Env>()
|
||||
});
|
||||
})
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user