diff --git a/apps/memos-local-plugin/bridge.cts b/apps/memos-local-plugin/bridge.cts index af796387c..913f10ce6 100644 --- a/apps/memos-local-plugin/bridge.cts +++ b/apps/memos-local-plugin/bridge.cts @@ -62,24 +62,71 @@ async function main(): Promise { pathToEsmUrl(path.resolve(__dirname, "server/http.ts")) )) as typeof import("./server/http.js"); - const pkgVersion = "2.0.0-alpha.1"; + const pkgVersion = require("./package.json").version; const { core, config, home } = await bootstrapMemoryCoreFull({ agent: args.agent, pkgVersion, }); await core.init(); - // Default transport: stdio. Daemon + TCP support arrives in V1.1. - const stdio = startStdioServer({ core }); - - // Per-agent fixed viewer port. We deliberately ignore - // `config.viewer.port` so old config.yaml files (which baked in - // the legacy single-port :18799) don't collide between agents. - // Users who really want a different port should `lsof`/`nc` the - // collision themselves rather than edit a YAML field. + // Per-agent fixed viewer port. const AGENT_DEFAULT_PORTS = { openclaw: 18799, hermes: 18800 } as const; const viewerPort = AGENT_DEFAULT_PORTS[args.agent]; + // ─── Daemon mode ────────────────────────────────────────────── + // When started with `--daemon`, skip stdio and run as a pure HTTP + // viewer daemon. Used by install.sh (post-install) and admin/restart + // (self-restart) to keep the Memory Viewer always available. + if (args.daemon) { + let viewer: import("./server/types.js").ServerHandle | null = null; + try { + viewer = await startHttpServer( + { + core, + home, + logTail: () => memoryBuffer().tail({ limit: 200 }), + }, + { + port: viewerPort, + host: config.viewer.bindHost, + staticRoot: path.resolve(__dirname, "web/dist"), + agent: args.agent, + }, + ); + process.stderr.write( + `bridge: daemon viewer live at ${viewer.url} (agent=${args.agent})\n`, + ); + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e?.code === "EADDRINUSE") { + process.stderr.write( + `bridge: daemon port :${viewerPort} already in use — exiting.\n`, + ); + await core.shutdown(); + process.exit(1); + } + process.stderr.write( + `bridge: daemon viewer failed: ${(err as Error)?.message ?? String(err)}\n`, + ); + await core.shutdown(); + process.exit(1); + } + + const shutdownDaemon = async (sig: string) => { + process.stderr.write(`bridge: daemon received ${sig}, shutting down\n`); + try { await viewer!.close(); } catch { /* best-effort */ } + await core.shutdown(); + process.exit(0); + }; + process.on("SIGINT", () => void shutdownDaemon("SIGINT")); + process.on("SIGTERM", () => void shutdownDaemon("SIGTERM")); + // Process stays alive via the HTTP server's ref'd socket. + return; + } + + // ─── Normal (stdio) mode ────────────────────────────────────── + const stdio = startStdioServer({ core }); + // Try to bind the viewer port. EADDRINUSE → stay headless. let viewer: import("./server/types.js").ServerHandle | null = null; try { @@ -135,27 +182,19 @@ async function main(): Promise { // If a viewer is running, keep the process alive as a daemon so the // memory panel stays accessible between `hermes chat` sessions. - // The next `hermes chat` will spawn a new headless bridge (EADDRINUSE - // on the viewer port); this daemon stays for the viewer only. if (viewer && !viewer.closed) { process.stderr.write( `bridge: stdin closed but viewer is still serving at ${viewer.url} — ` + `staying alive as daemon. Send SIGTERM to stop.\n`, ); - // Unref'd interval keeps the event loop alive without preventing - // graceful exit on SIGTERM/SIGINT (handled above). const keepalive = setInterval(() => { if (viewer!.closed) { clearInterval(keepalive); void core.shutdown().then(() => process.exit(0)); } }, 5_000); - // Don't let the keepalive timer keep the process alive if - // everything else (viewer, core) has been torn down. (keepalive as unknown as { unref?: () => void }).unref?.(); - // ...but DO ref the viewer's server socket so the process stays - // alive for HTTP requests. The server is already ref'd by default. - return; // don't fall through to shutdown + exit + return; } // No viewer (headless bridge) — clean exit. diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index d8a41dfa2..6a2c1338e 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -417,7 +417,12 @@ export function createMemoryCore( role: inferTurnRole(tc), action: phase === "lite" ? ("stored" as const) : ("reflected" as const), summary: tc.reflection?.text ?? null, - content: (tc.userText || tc.agentText || "").slice(0, 400), + content: ( + tc.userText || + tc.agentText || + summarizeToolCalls(tc.toolCalls) || + "" + ).slice(0, 400), traceId: tc.traceId, })); handle.repos.apiLogs.insert({ @@ -555,6 +560,40 @@ export function createMemoryCore( } async function health(): Promise { + // Read the latest on-disk config so that model names reflect what + // the user last saved, even before a restart applies the change. + let diskConfig: Record | null = null; + try { + const { loadConfig } = await import("../config/index.js"); + const { config } = await loadConfig(handle.home); + diskConfig = config as unknown as Record; + } catch { + /* fall through to in-memory */ + } + + const llmInfo = llmHealth(handle.llm, latestTraceTs()); + const embedderInfo = embedderHealth(handle.embedder, latestTraceTs()); + const skillEvolverInfo = resolveSkillEvolver( + diskConfig ?? handle.config, + handle.llm, + latestTraceTs(), + ); + + // Override model names from disk config if they differ from the + // in-memory client (user saved new settings but hasn't restarted). + if (diskConfig) { + const diskLlm = diskConfig.llm as { model?: string; provider?: string } | undefined; + if (diskLlm?.model && diskLlm.model !== llmInfo.model) { + llmInfo.model = diskLlm.model; + if (diskLlm.provider) llmInfo.provider = diskLlm.provider; + } + const diskEmb = diskConfig.embedding as { model?: string; provider?: string } | undefined; + if (diskEmb?.model && diskEmb.model !== embedderInfo.model) { + embedderInfo.model = diskEmb.model; + if (diskEmb.provider) embedderInfo.provider = diskEmb.provider; + } + } + return { ok: initialized && !shutDown, version: pkgVersion, @@ -567,17 +606,9 @@ export function createMemoryCore( skills: home.skillsDir, logs: home.logsDir, }, - // V7 overview card: fall back to the newest captured trace as - // a proxy for "LLM + embedder were OK recently" when the live - // `stats().lastOkAt` counter hasn't yet been populated in this - // process. Every captured trace is proof that reflection / α - // scoring (LLM) and summary embedding (embedder) both - // succeeded at that moment — so reading the DB max ts gives a - // correct, non-fabricated lower bound that survives plugin - // restarts without misleading the user. - llm: llmHealth(handle.llm, latestTraceTs()), - embedder: embedderHealth(handle.embedder, latestTraceTs()), - skillEvolver: resolveSkillEvolver(handle.config, handle.llm, latestTraceTs()), + llm: llmInfo, + embedder: embedderInfo, + skillEvolver: skillEvolverInfo, }; } @@ -1383,6 +1414,8 @@ export function createMemoryCore( tags: tagSet.size > 0 ? Array.from(tagSet).sort() : undefined, skillStatus: derivation.status, skillReason: derivation.reason, + skillReasonKey: derivation.reasonKey, + skillReasonParams: derivation.reasonParams, linkedSkillId: derivation.linkedSkillId, closeReason, abandonReason, @@ -2490,37 +2523,52 @@ export function deriveSkillStatus( ): { status: EpisodeListItemDTO["skillStatus"]; reason: string | null; + reasonKey: string | null; + reasonParams: Record | null; linkedSkillId: SkillId | null; } { if (ep.status === "open") { - return { status: "queued", reason: "任务仍在进行中,技能流水线尚未启动", linkedSkillId: null }; + return { + status: "queued", + reason: "任务仍在进行中,技能流水线尚未启动", + reasonKey: "tasks.skillReason.queued.inProgress", + reasonParams: null, + linkedSkillId: null, + }; } if (ep.rTask == null) { return { status: "queued", reason: "Reward 评分尚未完成,技能流水线将在评分后启动", + reasonKey: "tasks.skillReason.queued.rewardPending", + reasonParams: null, linkedSkillId: null, }; } if (ep.rTask <= R_NEGATIVE_FLOOR) { return { status: "skipped", - reason: `任务评分为明显负分 (R=${ep.rTask.toFixed(2)}),视为反例;不会沉淀出新的 L2 经验或技能,但原始 L1 轨迹会作为反面教材保留,在后续 Decision Repair 中生成 anti-pattern 规避下次同类错误`, + reason: `任务评分为明显负分 (R=${ep.rTask.toFixed(2)}),视为反例`, + reasonKey: "tasks.skillReason.skipped", + reasonParams: { rTask: ep.rTask.toFixed(2) }, linkedSkillId: null, }; } if (ep.rTask < R_BELOW_THRESHOLD) { return { status: "not_generated", - reason: `任务评分 R=${ep.rTask.toFixed(2)} 未达到沉淀阈值 (≥ ${R_BELOW_THRESHOLD.toFixed(2)})——对话本身正常,只是还不够强到能泛化成 L2 经验;多做几个相似任务后会自动积累`, + reason: `任务评分 R=${ep.rTask.toFixed(2)} 未达到沉淀阈值`, + reasonKey: "tasks.skillReason.not_generated.belowThreshold", + reasonParams: { rTask: ep.rTask.toFixed(2), threshold: R_BELOW_THRESHOLD.toFixed(2) }, linkedSkillId: null, }; } if (relatedPolicies.length === 0) { return { status: "not_generated", - reason: - "暂未归纳出 L2 经验——单个任务无法跨任务泛化;需要至少 2 个相似任务(minEpisodesForInduction),且 V 值 ≥ 0.1 才能触发 L2 诱导,之后支撑 ≥ 3 个相似任务才会结晶为技能", + reason: "暂未归纳出 L2 经验", + reasonKey: "tasks.skillReason.not_generated.noPolicy", + reasonParams: null, linkedSkillId: null, }; } @@ -2528,39 +2576,72 @@ export function deriveSkillStatus( const policyBucket = skillsByPolicy.get(best.id) ?? []; if (policyBucket.length > 0) { const active = policyBucket.find((s) => s.status !== "archived") ?? policyBucket[0]!; + const isUpgraded = best.updatedAt > active.updatedAt; return { - status: best.updatedAt > active.updatedAt ? "upgraded" : "generated", + status: isUpgraded ? "upgraded" : "generated", reason: `技能「${active.name ?? active.id}」已从经验 ${best.id.slice(0, 8)} 结晶`, + reasonKey: isUpgraded ? "tasks.skillReason.upgraded" : "tasks.skillReason.generated", + reasonParams: { skillName: active.name ?? active.id, policyId: best.id.slice(0, 8) }, linkedSkillId: active.id as SkillId, }; } if (best.status !== "active") { return { status: "queued", - reason: `经验 ${best.id.slice(0, 8)} 状态为 ${best.status}——需要更多支撑任务才能结晶为技能(当前 support=${best.support ?? 0},需 ≥3)`, + reason: `经验 ${best.id.slice(0, 8)} 需要更多支撑任务`, + reasonKey: "tasks.skillReason.queued.policyPending", + reasonParams: { support: String(best.support ?? 0) }, linkedSkillId: null, }; } return { status: "queued", - reason: `经验 ${best.id.slice(0, 8)} 已就绪(gain=${best.gain.toFixed(2)},support=${best.support ?? 0}),技能结晶将在下次 reward 评分后自动触发`, + reason: `经验 ${best.id.slice(0, 8)} 已就绪`, + reasonKey: "tasks.skillReason.queued.ready", + reasonParams: { gain: best.gain.toFixed(2), support: String(best.support ?? 0) }, linkedSkillId: null, }; } +/** + * Produce a short content string from toolCalls when userText/agentText + * are both empty (sub-steps after the first in a multi-tool turn). + */ +function summarizeToolCalls( + toolCalls?: readonly { name?: string; output?: unknown }[] | null, +): string { + if (!toolCalls || toolCalls.length === 0) return ""; + return toolCalls + .map((tc) => { + const name = tc.name ?? "tool"; + const out = typeof tc.output === "string" + ? tc.output.slice(0, 200) + : tc.output != null + ? JSON.stringify(tc.output).slice(0, 200) + : ""; + return out ? `[${name}] ${out}` : `[${name}]`; + }) + .join("\n"); +} + /** * Heuristic role inference for api_logs "memory_add" rows — mirrors * the legacy plugin's behaviour where each captured turn showed up * labelled `user` / `assistant` / `tool` on the Logs page. + * + * Priority: if the step carries userText (the user's query), label it + * "user" even when toolCalls are present — this is the first sub-step + * of a multi-tool turn and semantically represents the user request. */ function inferTurnRole(step: { userText?: string; agentText?: string; toolCalls?: readonly unknown[]; }): "user" | "assistant" | "tool" | "other" { - if ((step.toolCalls?.length ?? 0) > 0) return "tool"; const u = (step.userText ?? "").length; const a = (step.agentText ?? "").length; + if (u > 0 && (step.toolCalls?.length ?? 0) > 0) return "user"; + if ((step.toolCalls?.length ?? 0) > 0) return "tool"; if (u >= a && u > 0) return "user"; if (a > 0) return "assistant"; return "other"; diff --git a/apps/memos-local-plugin/install.sh b/apps/memos-local-plugin/install.sh index 0f4e6008b..94a79731e 100755 --- a/apps/memos-local-plugin/install.sh +++ b/apps/memos-local-plugin/install.sh @@ -687,38 +687,26 @@ CFGEOF if command -v lsof >/dev/null 2>&1 && lsof -i ":${HERMES_PORT}" -t >/dev/null 2>&1; then warn "Port :${HERMES_PORT} already in use — skipping smoke test." else - step "Running bridge smoke test" + step "Starting Memory Viewer daemon" local tsx_bin="${prefix}/node_modules/.bin/tsx" local bridge_cts="${prefix}/bridge.cts" if [[ -x "${tsx_bin}" && -f "${bridge_cts}" ]]; then - local smoke_log smoke_fifo smoke_pid sleeper_pid - smoke_log="$(mktemp)" - smoke_fifo="$(mktemp -u)" - mkfifo "${smoke_fifo}" - # Keep stdin open via a FIFO so the bridge doesn't exit on EOF. - sleep 60 > "${smoke_fifo}" & - sleeper_pid=$! - disown "${sleeper_pid}" 2>/dev/null || true - ( cd "${prefix}" && "${tsx_bin}" "${bridge_cts}" --agent=hermes <"${smoke_fifo}" >"${smoke_log}" 2>&1 ) & - smoke_pid=$! - disown "${smoke_pid}" 2>/dev/null || true + local daemon_log="${prefix}/logs/daemon-start.log" + mkdir -p "${prefix}/logs" + # Launch bridge in --daemon mode (pure HTTP, no stdio). + # The process stays alive to serve the Memory Viewer. + ( cd "${prefix}" && nohup "${tsx_bin}" "${bridge_cts}" --agent=hermes --daemon >"${daemon_log}" 2>&1 ) & + disown $! 2>/dev/null || true if wait_for_viewer "${HERMES_PORT}"; then - success "Bridge smoke test passed" + success "Memory Viewer daemon running" else error "Memory Viewer did not respond within 30s." warn "Re-install dependencies and re-run: cd ${prefix} && npm install" - kill "${smoke_pid}" "${sleeper_pid}" >/dev/null 2>&1 || true - kill -9 "${smoke_pid}" "${sleeper_pid}" >/dev/null 2>&1 || true - rm -f "${smoke_log}" "${smoke_fifo}" return 1 fi - - kill "${smoke_pid}" "${sleeper_pid}" >/dev/null 2>&1 || true - kill -9 "${smoke_pid}" "${sleeper_pid}" >/dev/null 2>&1 || true - rm -f "${smoke_log}" "${smoke_fifo}" else - warn "tsx not found — skipping smoke test." + warn "tsx not found — skipping daemon start." fi fi diff --git a/apps/memos-local-plugin/server/routes/admin.ts b/apps/memos-local-plugin/server/routes/admin.ts index f070ab9fe..9d27db0e9 100644 --- a/apps/memos-local-plugin/server/routes/admin.ts +++ b/apps/memos-local-plugin/server/routes/admin.ts @@ -12,8 +12,9 @@ * Agent-aware restart. For OpenClaw the plugin lives inside the * gateway process, which is managed by macOS launchd — calling * `process.exit(0)` causes launchd to respawn it automatically. - * For Hermes and other hosts, the endpoint is a no-op (the - * viewer shows a manual-restart toast instead). + * For Hermes: spawn a new bridge in --daemon mode, then exit the + * current process. The new daemon takes over the viewer port so + * the Memory Viewer stays available without user intervention. */ import type { ServerDeps, ServerOptions } from "../types.js"; import type { Routes } from "./registry.js"; @@ -38,11 +39,29 @@ export function registerAdminRoutes(routes: Routes, deps: ServerDeps, options: S routes.set("POST /api/v1/admin/restart", async (_ctx) => { const agent = options.agent ?? "unknown"; if (agent === "openclaw") { - // OpenClaw gateway is managed by launchd — exit and let it respawn. setTimeout(() => process.exit(0), 300); return { ok: true, restarting: true }; } - // Hermes / other hosts: no-op — the viewer shows a manual-restart toast. - return { ok: true, restarting: false, note: "config persisted; restart the agent process to apply" }; + // Hermes (and others): exit first (releasing the port), then a + // small wrapper script spawns the new daemon. We use a shell + // one-liner that sleeps briefly (for port release) then starts + // the new daemon. + const nodePath = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const { spawn } = await import("node:child_process"); + const thisFile = fileURLToPath(import.meta.url); + const pluginRoot = nodePath.resolve(nodePath.dirname(thisFile), "../.."); + const tsxBin = nodePath.join(pluginRoot, "node_modules/.bin/tsx"); + const bridgeScript = nodePath.join(pluginRoot, "bridge.cts"); + + const cmd = `sleep 1 && "${tsxBin}" "${bridgeScript}" --agent=${agent} --daemon`; + const child = spawn("bash", ["-c", cmd], { + detached: true, + stdio: "ignore", + cwd: pluginRoot, + }); + child.unref(); + setTimeout(() => process.exit(0), 200); + return { ok: true, restarting: true }; }); } diff --git a/apps/memos-local-plugin/server/routes/session.ts b/apps/memos-local-plugin/server/routes/session.ts index 1bc7f9fa5..68545d669 100644 --- a/apps/memos-local-plugin/server/routes/session.ts +++ b/apps/memos-local-plugin/server/routes/session.ts @@ -57,6 +57,7 @@ export function registerSessionRoutes(routes: Routes, deps: ServerDeps): void { routes.set("GET /api/v1/episodes", async (ctx) => { const sessionId = (ctx.url.searchParams.get("sessionId") as SessionId | null) ?? undefined; + const q = (ctx.url.searchParams.get("q") || "").trim().toLowerCase(); const rawLimit = numberOrUndefined(ctx.url.searchParams.get("limit")); const rawOffset = numberOrUndefined(ctx.url.searchParams.get("offset")); const limit = rawLimit && rawLimit > 0 ? rawLimit : 50; @@ -76,7 +77,24 @@ export function registerSessionRoutes(routes: Routes, deps: ServerDeps): void { nextOffset: episodeIds.length === limit ? offset + limit : undefined, }; } - const episodes = await deps.core.listEpisodeRows({ sessionId, limit, offset }); + let episodes = await deps.core.listEpisodeRows({ + sessionId, + limit: q ? 200 : limit, + offset: q ? 0 : offset, + }); + if (q) { + episodes = episodes.filter( + (ep: { preview?: string }) => ep.preview && ep.preview.toLowerCase().includes(q), + ); + const paged = episodes.slice(offset, offset + limit); + return { + episodes: paged, + limit, + offset, + total: episodes.length, + nextOffset: episodes.length > offset + limit ? offset + limit : undefined, + }; + } return { episodes, limit, diff --git a/apps/memos-local-plugin/server/routes/skill.ts b/apps/memos-local-plugin/server/routes/skill.ts index f6f14ffbd..44425cac4 100644 --- a/apps/memos-local-plugin/server/routes/skill.ts +++ b/apps/memos-local-plugin/server/routes/skill.ts @@ -14,14 +14,20 @@ import { parseJson, writeError, type Routes } from "./registry.js"; export function registerSkillRoutes(routes: Routes, deps: ServerDeps): void { routes.set("GET /api/v1/skills", async (ctx) => { const status = (ctx.url.searchParams.get("status") as SkillDTO["status"] | null) ?? undefined; + const q = (ctx.url.searchParams.get("q") || "").trim().toLowerCase(); // Viewer needs prev/next pagination — ask for one extra page so we // can tell the client whether there's more without a count query. const pageSize = limitOrUndefined(ctx.url.searchParams.get("limit")) ?? 50; const offset = Math.max(0, Number(ctx.url.searchParams.get("offset") ?? 0) || 0); - const all = await deps.core.listSkills({ status, limit: pageSize + offset + 1 }); + let all = await deps.core.listSkills({ status, limit: q ? 5000 : pageSize + offset + 1 }); + if (q) { + all = all.filter( + (s) => s.name.toLowerCase().includes(q) || s.invocationGuide.toLowerCase().includes(q), + ); + } const page = all.slice(offset, offset + pageSize); const hasMore = all.length > offset + pageSize; - const total = await deps.core.countSkills({ status }); + const total = q ? all.length : await deps.core.countSkills({ status }); return { skills: page, limit: pageSize, diff --git a/apps/memos-local-plugin/web/src/components/Header.tsx b/apps/memos-local-plugin/web/src/components/Header.tsx index cd12e34d4..5cce6e669 100644 --- a/apps/memos-local-plugin/web/src/components/Header.tsx +++ b/apps/memos-local-plugin/web/src/components/Header.tsx @@ -1,41 +1,183 @@ /** - * Top bar — brand (logo + version pill), global search, peer agents, - * theme + language switchers. The notification bell was removed in - * favour of inline toasts; per-event status now surfaces in Logs/Live. + * Top bar — brand (logo + version pill), global search with categorized + * dropdown, peer agents, theme + language switchers. */ -import { useState, useEffect } from "preact/hooks"; +import { useState, useEffect, useRef, useCallback } from "preact/hooks"; import { t } from "../stores/i18n"; import { health } from "../stores/health"; import { peers, discoverPeers } from "../stores/peers"; -import { Icon } from "./Icon"; +import { Icon, type IconName } from "./Icon"; import { navigate } from "../stores/router"; import { ThemeLangFooter } from "./ThemeLangFooter"; +import { api } from "../api/client"; + +interface SearchCategory { + key: string; + icon: IconName; + labelKey: string; + route: string; + items: { id: string; text: string }[]; + loading: boolean; +} export function Header() { const h = health.value; const [searchQ, setSearchQ] = useState(""); + const [showDropdown, setShowDropdown] = useState(false); + const [categories, setCategories] = useState([]); + const containerRef = useRef(null); + const abortRef = useRef(null); const runSearch = (e: Event) => { e.preventDefault(); const q = searchQ.trim(); if (!q) return; + setShowDropdown(false); navigate("/memories", { q }); }; - // Discover other agent viewers running on nearby ports once after - // health is known. Updates `peers` for the agent switcher. + const fetchResults = useCallback(async (q: string) => { + if (abortRef.current) abortRef.current.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + const empty: SearchCategory[] = [ + { key: "memories", icon: "brain-circuit", labelKey: "nav.memories", route: "/memories", items: [], loading: true }, + { key: "tasks", icon: "list-checks", labelKey: "nav.tasks", route: "/tasks", items: [], loading: true }, + { key: "skills", icon: "wand-sparkles", labelKey: "nav.skills", route: "/skills", items: [], loading: true }, + { key: "policies", icon: "sparkles", labelKey: "nav.policies", route: "/policies", items: [], loading: true }, + { key: "world-models", icon: "globe", labelKey: "nav.worldModels", route: "/world-models", items: [], loading: true }, + ]; + setCategories(empty); + setShowDropdown(true); + + const signal = ctrl.signal; + const limit = 3; + + const fetchers = [ + api + .get<{ traces: { id: string; summary?: string; userText?: string }[] }>( + `/api/v1/traces?q=${encodeURIComponent(q)}&limit=${limit}`, + { signal }, + ) + .then((r) => + (r.traces ?? []).map((t) => ({ + id: t.id, + text: (t.summary || t.userText || "").slice(0, 80), + })), + ) + .catch(() => [] as { id: string; text: string }[]), + + api + .get<{ episodes: { id: string; preview?: string }[] }>( + `/api/v1/episodes?q=${encodeURIComponent(q)}&limit=${limit}`, + { signal }, + ) + .then((r) => + (r.episodes ?? []).map((ep) => ({ + id: ep.id, + text: (ep.preview || "").slice(0, 80), + })), + ) + .catch(() => [] as { id: string; text: string }[]), + + api + .get<{ skills: { id: string; name: string }[] }>( + `/api/v1/skills?q=${encodeURIComponent(q)}&limit=${limit}`, + { signal }, + ) + .then((r) => + (r.skills ?? []).map((s) => ({ + id: s.id, + text: s.name, + })), + ) + .catch(() => [] as { id: string; text: string }[]), + + api + .get<{ policies: { id: string; title?: string; trigger?: string }[] }>( + `/api/v1/policies?q=${encodeURIComponent(q)}&limit=${limit}`, + { signal }, + ) + .then((r) => + (r.policies ?? []).map((p) => ({ + id: p.id, + text: (p.title || p.trigger || "").slice(0, 80), + })), + ) + .catch(() => [] as { id: string; text: string }[]), + + api + .get<{ worldModels: { id: string; title?: string }[] }>( + `/api/v1/world-models?q=${encodeURIComponent(q)}&limit=${limit}`, + { signal }, + ) + .then((r) => + (r.worldModels ?? []).map((w) => ({ + id: w.id, + text: (w.title || "").slice(0, 80), + })), + ) + .catch(() => [] as { id: string; text: string }[]), + ]; + + const results = await Promise.allSettled(fetchers); + if (signal.aborted) return; + + setCategories((prev) => + prev.map((cat, i) => ({ + ...cat, + items: results[i].status === "fulfilled" ? results[i].value : [], + loading: false, + })), + ); + }, []); + + useEffect(() => { + const q = searchQ.trim(); + if (!q) { + setShowDropdown(false); + setCategories([]); + return; + } + const timer = setTimeout(() => void fetchResults(q), 250); + return () => clearTimeout(timer); + }, [searchQ, fetchResults]); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowDropdown(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const handleItemClick = (cat: SearchCategory, itemId: string) => { + setShowDropdown(false); + setSearchQ(""); + navigate(cat.route, { q: searchQ.trim() }); + }; + + const handleCategoryMore = (cat: SearchCategory) => { + setShowDropdown(false); + setSearchQ(""); + navigate(cat.route, { q: searchQ.trim() }); + }; + const peerList = peers.value; useEffect(() => { if (!h?.agent) return; void discoverPeers(); }, [h?.agent]); + const totalResults = categories.reduce((sum, c) => sum + c.items.length, 0); + const anyLoading = categories.some((c) => c.loading); + return (
- {/* - * Brand: local MemOS logo + a small OpenClaw/Hermes agent icon. - */}