From f91eec2feffacfc8b3258deb826db459178d908b Mon Sep 17 00:00:00 2001 From: jiang Date: Fri, 8 May 2026 11:11:59 +0800 Subject: [PATCH 1/2] fix: improve memos local plugin OpenClaw behavior --- .test1.py | 3 + .../adapters/openclaw/tools.ts | 87 ++++++++++++++++--- .../core/pipeline/retrieval-repos.ts | 18 ++++ apps/memos-local-plugin/install.sh | 4 +- apps/memos-local-plugin/openclaw.plugin.json | 22 +++++ apps/memos-local-plugin/package.json | 1 + .../unit/adapters/openclaw-bridge.test.ts | 13 ++- .../tests/unit/adapters/openclaw-e2e.test.ts | 40 ++++++++- 8 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 .test1.py create mode 100644 apps/memos-local-plugin/openclaw.plugin.json diff --git a/.test1.py b/.test1.py new file mode 100644 index 00000000..44a3c19b --- /dev/null +++ b/.test1.py @@ -0,0 +1,3 @@ +import jieba.analyse +res = jieba.analyse.extract_tags("我爱旅游和烧烤", topK=12) +print(res) diff --git a/apps/memos-local-plugin/adapters/openclaw/tools.ts b/apps/memos-local-plugin/adapters/openclaw/tools.ts index 2effab7d..673d37dc 100644 --- a/apps/memos-local-plugin/adapters/openclaw/tools.ts +++ b/apps/memos-local-plugin/adapters/openclaw/tools.ts @@ -123,6 +123,28 @@ function clip(s: string | undefined, n: number): string { return s.length > n ? s.slice(0, n) + "…" : s; } +type TextToolContent = Array<{ type: "text"; text: string }>; + +function textToolResult>( + details: T, + text: string, +): T & { content: TextToolContent; details: T } { + return { + ...details, + content: [{ type: "text", text }], + details, + }; +} + +function formatHitList(hits: Array<{ refKind: string; refId: string; score: number; snippet: string }>): string { + if (hits.length === 0) return "No relevant memories found."; + const lines = hits.map((h, i) => { + const snippet = h.snippet.trim() || "(empty snippet)"; + return `${i + 1}. [${h.refKind}:${h.refId}] ${snippet} (score=${h.score.toFixed(3)})`; + }); + return `Found ${hits.length} memories:\n\n${lines.join("\n")}`; +} + function sessionFromCtx(ctx: OpenClawPluginToolContext | undefined): string | undefined { const sessionKey = ctx?.sessionKey; if (!sessionKey) return undefined; @@ -178,7 +200,7 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions query: params.query, topK: topKParams(params, maxResults), }); - return { + const details = { hits: result.hits.map((h) => ({ tier: h.tier, refKind: h.refKind, @@ -188,6 +210,7 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions })), totalMs: Date.now() - started, }; + return textToolResult(details, formatHitList(details.hits)); }, }), { name: "memory_search" }, @@ -207,8 +230,11 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions const kind = params.kind ?? "trace"; if (kind === "trace") { const trace = await core.getTrace(params.id as TraceId, namespaceFromCtx(ctx)); - if (!trace) return { found: false, kind, id: params.id, body: "", meta: {} }; - return { + if (!trace) { + const details = { found: false, kind, id: params.id, body: "", meta: {} }; + return textToolResult(details, `No ${kind} memory found for id "${params.id}".`); + } + const details = { found: true, kind, id: trace.id, @@ -226,11 +252,15 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions })), }, }; + return textToolResult(details, details.body || trace.summary || trace.userText || `Found trace ${trace.id}.`); } if (kind === "policy") { const policy = await core.getPolicy(params.id, namespaceFromCtx(ctx)); - if (!policy) return { found: false, kind, id: params.id, body: "", meta: {} }; - return { + if (!policy) { + const details = { found: false, kind, id: params.id, body: "", meta: {} }; + return textToolResult(details, `No ${kind} memory found for id "${params.id}".`); + } + const details = { found: true, kind, id: policy.id, @@ -244,16 +274,21 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions status: policy.status, }, }; + return textToolResult(details, details.body); } const wm = await core.getWorldModel(params.id, namespaceFromCtx(ctx)); - if (!wm) return { found: false, kind, id: params.id, body: "", meta: {} }; - return { + if (!wm) { + const details = { found: false, kind, id: params.id, body: "", meta: {} }; + return textToolResult(details, `No ${kind} memory found for id "${params.id}".`); + } + const details = { found: true, kind, id: wm.id, body: clip(wm.body, bodyCap), meta: { title: wm.title, policyIds: wm.policyIds }, }; + return textToolResult(details, `${wm.title}\n\n${details.body}`.trim()); }, }), { name: "memory_get" }, @@ -272,7 +307,7 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions const core = await resolveCore(opts); const traces = await core.timeline({ episodeId: params.episodeId as never, namespace: namespaceFromCtx(ctx) }); const limited = traces.slice(0, params.limit ?? 20); - return { + const details = { episodeId: params.episodeId, traces: limited.map((t) => ({ id: t.id, @@ -283,6 +318,13 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions value: t.value, })), }; + const text = details.traces.length === 0 + ? `No traces found for episode "${params.episodeId}".` + : `Episode ${params.episodeId} timeline:\n\n` + + details.traces + .map((t, i) => `${i + 1}. ${t.userText || t.agentText || t.id}`) + .join("\n"); + return textToolResult(details, text); }, }), { name: "memory_timeline" }, @@ -303,7 +345,7 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions limit: params.limit, namespace: namespaceFromCtx(ctx), }); - return { + const details = { skills: skills.map((s) => ({ id: s.id, name: s.name, @@ -314,6 +356,11 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions invocationGuide: clip(s.invocationGuide, bodyCap), })), }; + const text = details.skills.length === 0 + ? "No skills found." + : `Found ${details.skills.length} skills:\n\n` + + details.skills.map((s, i) => `${i + 1}. ${s.name} (${s.id}, ${s.status})`).join("\n"); + return textToolResult(details, text); }, }), { name: "skill_list" }, @@ -348,7 +395,7 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions const core = await resolveCore(opts); if (!query) { const rows = await core.listWorldModels({ limit: cap, offset: 0, namespace: namespaceFromCtx(ctx) }); - return { + const details = { worldModels: rows.map((w) => ({ id: w.id, title: w.title, @@ -358,6 +405,11 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions })), queried: false, }; + const text = details.worldModels.length === 0 + ? "No environment knowledge found." + : `Environment knowledge:\n\n` + + details.worldModels.map((w, i) => `${i + 1}. ${w.title}\n${w.body}`).join("\n\n"); + return textToolResult(details, text); } // With a query, go through `searchMemory` so tag filters + // cosine ranking apply, then keep only the tier-3 hits. @@ -368,7 +420,7 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions topK: { tier1: 0, tier2: 0, tier3: cap }, }); const tier3 = res.hits.filter((h) => h.tier === 3); - return { + const details = { worldModels: tier3.map((h) => ({ id: h.refId, title: (h.snippet ?? "").split("\n")[0]?.replace(/^World model:\s*/, "") ?? "", @@ -378,6 +430,11 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions })), queried: true, }; + const text = details.worldModels.length === 0 + ? "No matching environment knowledge found." + : `Environment knowledge for "${query}":\n\n` + + details.worldModels.map((w, i) => `${i + 1}. ${w.title}\n${w.body}`).join("\n\n"); + return textToolResult(details, text); }, }), { name: "memory_environment" }, @@ -399,8 +456,11 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions namespace: namespaceFromCtx(ctx), toolCallId, }); - if (!skill) return { found: false, skill: null }; - return { + if (!skill) { + const details = { found: false, skill: null }; + return textToolResult(details, `No skill found for id "${params.id}".`); + } + const details = { found: true, skill: { id: skill.id, @@ -418,6 +478,7 @@ export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions lastUsedAt: skill.lastUsedAt, }, }; + return textToolResult(details, `${skill.name}\n\n${skill.invocationGuide}`.trim()); }, }), { name: "skill_get" }, diff --git a/apps/memos-local-plugin/core/pipeline/retrieval-repos.ts b/apps/memos-local-plugin/core/pipeline/retrieval-repos.ts index 74736bac..9b8e4e5f 100644 --- a/apps/memos-local-plugin/core/pipeline/retrieval-repos.ts +++ b/apps/memos-local-plugin/core/pipeline/retrieval-repos.ts @@ -18,6 +18,12 @@ export function wrapRetrievalRepos(repos: Repos, namespace: RuntimeNamespace): R searchByVector(query, k, opts) { return repos.skills.searchByVector(query, k, opts ?? {}); }, + searchByText(ftsMatch, k, opts) { + return repos.skills.searchByText(ftsMatch, k, opts ?? {}); + }, + searchByPattern(terms, k, opts) { + return repos.skills.searchByPattern(terms, k, opts ?? {}); + }, getById(id) { const row = repos.skills.getById(id); if (!row || !isVisibleTo(row, namespace)) return null; @@ -35,6 +41,12 @@ export function wrapRetrievalRepos(repos: Repos, namespace: RuntimeNamespace): R searchByVector(query, k, opts) { return repos.traces.searchByVector(query, k, opts ?? {}); }, + searchByText(ftsMatch, k, opts) { + return repos.traces.searchByText(ftsMatch, k, opts ?? {}); + }, + searchByPattern(terms, k, opts) { + return repos.traces.searchByPattern(terms, k, opts ?? {}); + }, getManyByIds(ids) { const rows = repos.traces.getManyByIds(ids as readonly TraceId[]); return rows.filter((r) => isVisibleTo(r, namespace)).map((r) => ({ @@ -74,6 +86,12 @@ export function wrapRetrievalRepos(repos: Repos, namespace: RuntimeNamespace): R searchByVector(query, k, opts) { return repos.worldModel.searchByVector(query, k, opts ?? {}); }, + searchByText(ftsMatch, k) { + return repos.worldModel.searchByText(ftsMatch, k); + }, + searchByPattern(terms, k) { + return repos.worldModel.searchByPattern(terms, k); + }, getById(id) { const row = repos.worldModel.getById(id); if (!row || !isVisibleTo(row, namespace)) return null; diff --git a/apps/memos-local-plugin/install.sh b/apps/memos-local-plugin/install.sh index 9782dec1..25547402 100755 --- a/apps/memos-local-plugin/install.sh +++ b/apps/memos-local-plugin/install.sh @@ -377,7 +377,7 @@ ensure_runtime_home() { wait_for_viewer() { local port="$1" local url="http://127.0.0.1:${port}" - local timeout="${2:-30}" + local timeout="${2:-60}" local frames=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") local idx=0 local elapsed=0 @@ -599,7 +599,7 @@ NODE return 0 fi - warn "Memory Viewer did not respond within 30s." + warn "Memory Viewer did not respond within 60s." printf " ${DIM}Check: /tmp/openclaw-memos-gateway.log or /tmp/openclaw/openclaw-*.log${NC}\n" >&2 return 1 } diff --git a/apps/memos-local-plugin/openclaw.plugin.json b/apps/memos-local-plugin/openclaw.plugin.json new file mode 100644 index 00000000..fc952fec --- /dev/null +++ b/apps/memos-local-plugin/openclaw.plugin.json @@ -0,0 +1,22 @@ +{ + "id": "memos-local-plugin", + "name": "MemOS Local", + "description": "Reflect2Evolve memory plugin with L1 traces, L2 policies, L3 world models, skill crystallization, and three-tier retrieval.", + "version": "2.0.0-beta.5", + "kind": "memory", + "contracts": { + "tools": [ + "memory_search", + "memory_get", + "memory_timeline", + "memory_environment", + "skill_list", + "skill_get" + ] + }, + "configSchema": { + "type": "object", + "additionalProperties": true, + "properties": {} + } +} diff --git a/apps/memos-local-plugin/package.json b/apps/memos-local-plugin/package.json index 878965a8..b987f92f 100644 --- a/apps/memos-local-plugin/package.json +++ b/apps/memos-local-plugin/package.json @@ -15,6 +15,7 @@ }, "files": [ "dist", + "openclaw.plugin.json", "bridge.cts", "bridge", "core", diff --git a/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts b/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts index 71258633..7a157d92 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/openclaw-bridge.test.ts @@ -1361,9 +1361,20 @@ describe("registerOpenClawTools", () => { const res = (await search.descriptor.execute("toolCall_1", { query: "anything", maxResults: 5, - })) as { hits: Array; totalMs: number }; + })) as { + hits: Array; + totalMs: number; + content: Array<{ type: "text"; text: string }>; + details: { hits: Array; totalMs: number }; + }; expect(Array.isArray(res.hits)).toBe(true); expect(res.totalMs).toBeGreaterThanOrEqual(0); + // Latest OpenClaw's MCP plugin-tools bridge serializes only + // `result.content`; keep that populated while preserving the older + // top-level object shape used by local tests and callers. + expect(res.content[0]?.type).toBe("text"); + expect(res.content[0]?.text).toContain("memories"); + expect(res.details.hits).toBe(res.hits); }); it("memory_search maps per-tier topK params and keeps maxResults fallback", async () => { diff --git a/apps/memos-local-plugin/tests/unit/adapters/openclaw-e2e.test.ts b/apps/memos-local-plugin/tests/unit/adapters/openclaw-e2e.test.ts index 16e47054..f98ef8a4 100644 --- a/apps/memos-local-plugin/tests/unit/adapters/openclaw-e2e.test.ts +++ b/apps/memos-local-plugin/tests/unit/adapters/openclaw-e2e.test.ts @@ -132,7 +132,7 @@ function silentLogger(): HostLogger { }; } -function buildDeps(h: TmpDbHandle): PipelineDeps { +function buildDeps(h: TmpDbHandle, embedder: Embedder | null = semanticFakeEmbedder(DEFAULT_CONFIG.embedding.dimensions)): PipelineDeps { return { agent: "openclaw", home: resolveHome("openclaw", "/tmp/memos-e2e-test"), @@ -141,15 +141,15 @@ function buildDeps(h: TmpDbHandle): PipelineDeps { repos: h.repos, llm: null, reflectLlm: null, - embedder: semanticFakeEmbedder(DEFAULT_CONFIG.embedding.dimensions), + embedder, log: rootLogger.child({ channel: "test.adapters.openclaw.e2e" }), namespace: { agentKind: "openclaw", profileId: "main" }, now: () => 1_700_000_000_000, }; } -function buildCore(): MemoryCore { - pipeline = createPipeline(buildDeps(db!)); +function buildCore(embedder?: Embedder | null): MemoryCore { + pipeline = createPipeline(buildDeps(db!, embedder)); const mc = createMemoryCore( pipeline, resolveHome("openclaw", "/tmp/memos-e2e-test"), @@ -335,6 +335,38 @@ describe("OpenClaw adapter — end-to-end memory chain", () => { expect(allText).toContain("榴莲"); }); + it("retrieves freshly captured text through keyword fallback when embeddings are unavailable", async () => { + const mc = buildCore(null); + await mc.init(); + const bridge = createOpenClawBridge({ agent: "openclaw", core: mc, log: silentLogger() }); + + await bridge.handleBeforePrompt( + { prompt: "记住:我喜欢蓝莓酸奶", messages: [] }, + ctx({ sessionKey: "keyword-thread" }), + ); + await bridge.handleAgentEnd( + { + success: true, + messages: [ + { role: "user", content: "记住:我喜欢蓝莓酸奶" }, + { role: "assistant", content: "好的,我记住了。" }, + ], + durationMs: 80, + }, + ctx({ sessionKey: "keyword-thread" }), + ); + await (pipeline as PipelineHandle).flush(); + + const search = await mc.searchMemory({ + agent: "openclaw", + query: "蓝莓酸奶", + topK: { tier1: 0, tier2: 5, tier3: 0 }, + }); + + expect(search.hits.length).toBeGreaterThan(0); + expect(search.hits.map((h) => h.snippet).join("\n")).toContain("蓝莓酸奶"); + }); + it("toolCalls captured during agent_end are written into the trace row", async () => { const mc = buildCore(); await mc.init(); From d0b5acff57d6cb6fc612fe3232432942767e7398 Mon Sep 17 00:00:00 2001 From: jiang Date: Fri, 8 May 2026 11:36:20 +0800 Subject: [PATCH 2/2] feat: add detailed log controls Gate advanced log filters and chain view behind a settings toggle, keep default logs focused on memory add/search, and remove task bulk delete from the task panel. --- .../agent-contract/memory-core.ts | 1 + .../core/config/defaults.ts | 1 + apps/memos-local-plugin/core/config/schema.ts | 2 + .../core/pipeline/memory-core.ts | 7 +- .../core/storage/repos/api_logs.ts | 60 ++++++- .../server/routes/api-logs.ts | 6 + .../templates/config.hermes.yaml | 1 + .../templates/config.openclaw.yaml | 1 + .../tests/unit/server/http.test.ts | 13 ++ .../memos-local-plugin/web/src/stores/i18n.ts | 6 + .../web/src/views/LogsView.tsx | 164 ++++++++++++------ .../web/src/views/SettingsView.tsx | 23 ++- .../web/src/views/TasksView.tsx | 18 -- 13 files changed, 228 insertions(+), 75 deletions(-) diff --git a/apps/memos-local-plugin/agent-contract/memory-core.ts b/apps/memos-local-plugin/agent-contract/memory-core.ts index 45943c3f..6c846fb0 100644 --- a/apps/memos-local-plugin/agent-contract/memory-core.ts +++ b/apps/memos-local-plugin/agent-contract/memory-core.ts @@ -357,6 +357,7 @@ export interface MemoryCore { */ listApiLogs(input?: { toolName?: string; + toolNames?: readonly string[]; limit?: number; offset?: number; }): Promise<{ logs: ApiLogDTO[]; total: number }>; diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 11e36553..d6d3dae2 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -249,6 +249,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { telemetry: { enabled: true }, logging: { level: "info", + detailedView: false, console: { enabled: true, pretty: true, channels: ["*"] }, file: { enabled: true, diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 5dba1b58..545229af 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -456,6 +456,8 @@ const LoggingSchema = Type.Object({ Type.Literal("error"), Type.Literal("fatal"), ], { default: "info" }), + /** Viewer-only switch: expose detailed logs, lifecycle tags and chain view. */ + detailedView: Bool(false), console: Type.Object({ enabled: Bool(true), pretty: Bool(true), diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 9a7e7858..ded7a376 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -2451,6 +2451,7 @@ export function createMemoryCore( async function listApiLogs(input?: { toolName?: string; + toolNames?: readonly string[]; limit?: number; offset?: number; }): Promise<{ logs: ApiLogDTO[]; total: number }> { @@ -2459,10 +2460,14 @@ export function createMemoryCore( const offset = Math.max(0, input?.offset ?? 0); const rows = handle.repos.apiLogs.list({ toolName: input?.toolName, + toolNames: input?.toolNames, limit, offset, }); - const total = handle.repos.apiLogs.count({ toolName: input?.toolName }); + const total = handle.repos.apiLogs.count({ + toolName: input?.toolName, + toolNames: input?.toolNames, + }); return { logs: rows.map((r) => ({ id: r.id, diff --git a/apps/memos-local-plugin/core/storage/repos/api_logs.ts b/apps/memos-local-plugin/core/storage/repos/api_logs.ts index 187af461..f0cbb7f1 100644 --- a/apps/memos-local-plugin/core/storage/repos/api_logs.ts +++ b/apps/memos-local-plugin/core/storage/repos/api_logs.ts @@ -37,8 +37,10 @@ export interface ApiLogInsert { } export interface ApiLogFilter { - /** Filter by tool name — `memory_search` or `memory_add`. */ + /** Filter by a single tool name. */ toolName?: string; + /** Filter by several tool names while preserving newest-first pagination. */ + toolNames?: readonly string[]; /** Default 50; max 500 to keep viewer paint times sane. */ limit?: number; offset?: number; @@ -85,6 +87,45 @@ export function makeApiLogsRepo(db: StorageDb) { LIMIT @limit OFFSET @offset`, ); + const countByToolNames = (toolNames: readonly string[]): number => { + const names = normalizeToolNames(toolNames); + if (names.length === 0) return countAll.get({})?.n ?? 0; + if (names.length === 1) { + return countByTool.get({ tool_name: names[0]! })?.n ?? 0; + } + const params = namedToolParams(names); + const placeholders = Object.keys(params).map((key) => `@${key}`).join(", "); + const row = db + .prepare, { n: number }>( + `SELECT COUNT(*) AS n FROM api_logs WHERE tool_name IN (${placeholders})`, + ) + .get(params); + return row?.n ?? 0; + }; + + const selectByToolNames = ( + toolNames: readonly string[], + limit: number, + offset: number, + ): RawRow[] => { + const names = normalizeToolNames(toolNames); + if (names.length === 0) return selectAll.all({ limit, offset }); + if (names.length === 1) { + return selectByTool.all({ tool_name: names[0]!, limit, offset }); + } + const toolParams = namedToolParams(names); + const placeholders = Object.keys(toolParams).map((key) => `@${key}`).join(", "); + return db + .prepare, RawRow>( + `SELECT id, tool_name, input_json, output_json, duration_ms, success, called_at + FROM api_logs + WHERE tool_name IN (${placeholders}) + ORDER BY called_at DESC, id DESC + LIMIT @limit OFFSET @offset`, + ) + .all({ ...toolParams, limit, offset }); + }; + return { insert(row: ApiLogInsert): void { insert.run({ @@ -97,7 +138,10 @@ export function makeApiLogsRepo(db: StorageDb) { }); }, - count(filter: Pick = {}): number { + count(filter: Pick = {}): number { + if (filter.toolNames?.length) { + return countByToolNames(filter.toolNames); + } if (filter.toolName) { return countByTool.get({ tool_name: filter.toolName })?.n ?? 0; } @@ -107,7 +151,9 @@ export function makeApiLogsRepo(db: StorageDb) { list(filter: ApiLogFilter = {}): ApiLogRow[] { const limit = Math.max(1, Math.min(500, filter.limit ?? 50)); const offset = Math.max(0, filter.offset ?? 0); - const rows = filter.toolName + const rows = filter.toolNames?.length + ? selectByToolNames(filter.toolNames, limit, offset) + : filter.toolName ? selectByTool.all({ tool_name: filter.toolName, limit, offset }) : selectAll.all({ limit, offset }); return rows.map(mapRow); @@ -115,6 +161,14 @@ export function makeApiLogsRepo(db: StorageDb) { }; } +function normalizeToolNames(toolNames: readonly string[]): string[] { + return [...new Set(toolNames.map((name) => name.trim()).filter(Boolean))]; +} + +function namedToolParams(toolNames: readonly string[]): Record { + return Object.fromEntries(toolNames.map((name, index) => [`tool_${index}`, name])); +} + interface RawRow { id: number; tool_name: string; diff --git a/apps/memos-local-plugin/server/routes/api-logs.ts b/apps/memos-local-plugin/server/routes/api-logs.ts index 8d4b77dc..8fc59b5f 100644 --- a/apps/memos-local-plugin/server/routes/api-logs.ts +++ b/apps/memos-local-plugin/server/routes/api-logs.ts @@ -6,6 +6,7 @@ * * Query parameters: * - `tool` optional tool-name filter (e.g. `memory_search`) + * - `tools` optional comma-separated tool-name filter * - `limit` default 50, capped server-side at 500 * - `offset` default 0 * @@ -25,8 +26,13 @@ export function registerApiLogsRoutes(routes: Routes, deps: ServerDeps): void { const offset = Number.isFinite(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0; const tool = params.get("tool") || undefined; + const tools = (params.get("tools") ?? "") + .split(",") + .map((name) => name.trim()) + .filter(Boolean); const res = await deps.core.listApiLogs({ toolName: tool, + toolNames: tool ? undefined : tools, limit, offset, }); diff --git a/apps/memos-local-plugin/templates/config.hermes.yaml b/apps/memos-local-plugin/templates/config.hermes.yaml index b47fe558..10420f55 100644 --- a/apps/memos-local-plugin/templates/config.hermes.yaml +++ b/apps/memos-local-plugin/templates/config.hermes.yaml @@ -38,3 +38,4 @@ telemetry: logging: level: info # trace | debug | info | warn | error | fatal + detailedView: false # show advanced log filters / chain view in the viewer diff --git a/apps/memos-local-plugin/templates/config.openclaw.yaml b/apps/memos-local-plugin/templates/config.openclaw.yaml index 170c337f..2ad0fea0 100644 --- a/apps/memos-local-plugin/templates/config.openclaw.yaml +++ b/apps/memos-local-plugin/templates/config.openclaw.yaml @@ -37,3 +37,4 @@ telemetry: logging: level: info # trace | debug | info | warn | error | fatal + detailedView: false # show advanced log filters / chain view in the viewer diff --git a/apps/memos-local-plugin/tests/unit/server/http.test.ts b/apps/memos-local-plugin/tests/unit/server/http.test.ts index 635d2449..cbe4f8fa 100644 --- a/apps/memos-local-plugin/tests/unit/server/http.test.ts +++ b/apps/memos-local-plugin/tests/unit/server/http.test.ts @@ -360,6 +360,19 @@ describe("HTTP server — REST routes", () => { expect(core.getTrace).toHaveBeenCalledWith("t-42"); }); + it("GET /api/v1/api-logs supports multi-tool filtering", async () => { + const r = await fetch( + `${handle.url}/api/v1/api-logs?tools=memory_add,memory_search&limit=10&offset=5`, + ); + expect(r.status).toBe(200); + expect(core.listApiLogs).toHaveBeenCalledWith({ + toolName: undefined, + toolNames: ["memory_add", "memory_search"], + limit: 10, + offset: 5, + }); + }); + it("GET /api/v1/episodes/:id/timeline returns {episodeId, traces}", async () => { const r = await fetch(`${handle.url}/api/v1/episodes/e1/timeline`); expect(r.status).toBe(200); diff --git a/apps/memos-local-plugin/web/src/stores/i18n.ts b/apps/memos-local-plugin/web/src/stores/i18n.ts index bc0aab2e..06423fc9 100644 --- a/apps/memos-local-plugin/web/src/stores/i18n.ts +++ b/apps/memos-local-plugin/web/src/stores/i18n.ts @@ -708,6 +708,9 @@ const en = { "settings.general.theme.light": "Light", "settings.general.theme.dark": "Dark", "settings.general.theme.auto": "System", + "settings.general.detailedLogs": "Show detailed debug logs", + "settings.general.detailedLogs.desc": + "Enable chain view, failure-only filtering, and task, experience, skill, environment and system log categories.", "settings.general.telemetry": "Enable anonymous usage stats", "settings.general.telemetry.desc": "Only tool names, response times and the version number are collected. No memory content, queries or personal data ever leave this machine.", @@ -1361,6 +1364,9 @@ const zh: Record = { "settings.general.theme.light": "浅色", "settings.general.theme.dark": "深色", "settings.general.theme.auto": "跟随系统", + "settings.general.detailedLogs": "显示详细调试日志", + "settings.general.detailedLogs.desc": + "开启后显示链路视图、仅看失败筛选,以及任务、经验、技能、环境认知和系统日志分类。", "settings.general.telemetry": "启用匿名数据统计", "settings.general.telemetry.desc": "仅收集工具名称、响应时间和版本号,不涉及任何记忆内容或个人数据。", diff --git a/apps/memos-local-plugin/web/src/views/LogsView.tsx b/apps/memos-local-plugin/web/src/views/LogsView.tsx index b45710fd..abbd4fba 100644 --- a/apps/memos-local-plugin/web/src/views/LogsView.tsx +++ b/apps/memos-local-plugin/web/src/views/LogsView.tsx @@ -73,11 +73,14 @@ const LOG_TAGS: Array<{ v: LogTag; k: string }> = [ { v: "system", k: "logs.tag.system" }, ]; +const BASIC_LOG_TAGS = LOG_TAGS.filter((tag) => + tag.v === "" || tag.v === "memory_add" || tag.v === "memory_search" +); + /** * Backend `toolName` values that each frontend tag selects. When the - * array has exactly one entry, we send `?tool=` to the server for - * efficient filtering; otherwise (generate + evolve, or task_done + - * task_failed) we over-fetch and filter client-side. + * array has exactly one entry, we send `?tool=` to the server; with + * multiple entries list mode sends `?tools=` for efficient filtering. */ const ALLOWED_TOOLS: Record = { "": [], @@ -91,6 +94,11 @@ const ALLOWED_TOOLS: Record = { system: ["system_error", "system_model_status"], }; +const BASIC_LOG_TOOLS = [ + "memory_add", + "memory_search", +] as const satisfies readonly ToolFilter[]; + interface ApiLogsResponse { logs: ApiLogDTO[]; total: number; @@ -99,16 +107,29 @@ interface ApiLogsResponse { nextOffset?: number; } +interface ViewerConfig { + logging?: { + detailedView?: boolean; + }; +} + const DEFAULT_PAGE_SIZE = 25; const CHAIN_FETCH_LIMIT = 800; type ViewMode = "chain" | "list"; +function allowedToolsForTag(tag: LogTag, detailedLogs: boolean): readonly ToolFilter[] { + if (detailedLogs) return ALLOWED_TOOLS[tag]; + if (tag === "memory_add" || tag === "memory_search") return ALLOWED_TOOLS[tag]; + return BASIC_LOG_TOOLS; +} + export function LogsView() { const [viewMode, setViewMode] = useState("chain"); const [tag, setTag] = useState(""); const [query, setQuery] = useState(""); const [failuresOnly, setFailuresOnly] = useState(false); + const [detailedLogs, setDetailedLogs] = useState(false); const [logs, setLogs] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(0); @@ -120,15 +141,37 @@ export function LogsView() { // initialisation when filters change. const [expandedChains, setExpandedChains] = useState>(new Set()); + useEffect(() => { + const ctrl = new AbortController(); + api + .get("/api/v1/config", { signal: ctrl.signal }) + .then((config) => setDetailedLogs(!!config.logging?.detailedView)) + .catch((err) => { + if ((err as Error).name !== "AbortError") setDetailedLogs(false); + }); + return () => ctrl.abort(); + }, []); + + useEffect(() => { + if (detailedLogs) return; + if (tag !== "" && tag !== "memory_add" && tag !== "memory_search") { + setTag(""); + } + if (failuresOnly) setFailuresOnly(false); + setExpandedChains(new Set()); + }, [detailedLogs, failuresOnly, tag]); + // Chain mode always over-fetches a wide window so episode-level // grouping has enough material to work with. List mode keeps the // legacy single-tool SQL filter for cheap pagination. - const currentAllowed = ALLOWED_TOOLS[tag]; + const visibleLogTags = detailedLogs ? LOG_TAGS : BASIC_LOG_TAGS; + const effectiveViewMode: ViewMode = detailedLogs ? viewMode : "list"; + const effectiveFailuresOnly = detailedLogs ? failuresOnly : false; + const currentAllowed = allowedToolsForTag(tag, detailedLogs); const clientFilterActive = - viewMode === "chain" || - currentAllowed.length > 1 || + effectiveViewMode === "chain" || query.trim().length > 0 || - failuresOnly; + effectiveFailuresOnly; const load = async (opts: { tag: LogTag; @@ -136,18 +179,20 @@ export function LogsView() { query: string; viewMode: ViewMode; failuresOnly: boolean; + detailedLogs: boolean; }) => { setLoading(true); try { const qs = new URLSearchParams(); - const allowed = ALLOWED_TOOLS[opts.tag]; + const allowed = allowedToolsForTag(opts.tag, opts.detailedLogs); + const optsViewMode: ViewMode = opts.detailedLogs ? opts.viewMode : "list"; + const optsFailuresOnly = opts.detailedLogs ? opts.failuresOnly : false; const needsClient = - opts.viewMode === "chain" || - allowed.length > 1 || + optsViewMode === "chain" || opts.query.trim().length > 0 || - opts.failuresOnly; + optsFailuresOnly; const limit = - opts.viewMode === "chain" + optsViewMode === "chain" ? CHAIN_FETCH_LIMIT : needsClient ? 500 @@ -157,8 +202,10 @@ export function LogsView() { // Tool-side SQL filtering is a list-mode optimisation. Chain // mode intentionally fetches across tools so grouped events // form a complete pipeline trace. - if (opts.viewMode === "list" && allowed.length === 1) { + if (optsViewMode === "list" && allowed.length === 1) { qs.set("tool", allowed[0]!); + } else if (optsViewMode === "list" && allowed.length > 1) { + qs.set("tools", allowed.join(",")); } const res = await api.get(`/api/v1/api-logs?${qs.toString()}`); setLogs(res.logs); @@ -173,18 +220,18 @@ export function LogsView() { }; useEffect(() => { - void load({ tag, page: 0, query, viewMode, failuresOnly }); + void load({ tag, page: 0, query, viewMode, failuresOnly, detailedLogs }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tag, pageSize, viewMode, failuresOnly]); + }, [tag, pageSize, viewMode, failuresOnly, detailedLogs]); // Debounced client-side refresh when the search query changes. useEffect(() => { const h = setTimeout(() => { - void load({ tag, page: 0, query, viewMode, failuresOnly }); + void load({ tag, page: 0, query, viewMode, failuresOnly, detailedLogs }); }, 200); return () => clearTimeout(h); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, pageSize]); + }, [query, pageSize, detailedLogs]); const toggleExpand = (id: number) => { setExpanded((prev) => { @@ -211,7 +258,7 @@ export function LogsView() { const filtered = clientFilterActive ? logs.filter((log) => { if (currentAllowed.length > 0 && !currentAllowed.includes(log.toolName as ToolFilter)) return false; - if (failuresOnly && log.success) return false; + if (effectiveFailuresOnly && log.success) return false; if (!needle) return true; const hay = `${log.toolName} ${log.inputJson ?? ""} ${log.outputJson ?? ""}`.toLowerCase(); return hay.includes(needle); @@ -224,9 +271,9 @@ export function LogsView() { // Chain view: regroup by episodeId (fallback sessionId). Filters // are applied as "match any event in the chain". - const allChains = viewMode === "chain" ? aggregateChains(logs) : []; + const allChains = effectiveViewMode === "chain" ? aggregateChains(logs) : []; const filteredChains = - viewMode === "chain" + effectiveViewMode === "chain" ? allChains.filter((chain) => { if (currentAllowed.length > 0) { const hit = chain.events.some((ev) => @@ -234,7 +281,7 @@ export function LogsView() { ); if (!hit) return false; } - if (failuresOnly && chain.failureCount === 0) return false; + if (effectiveFailuresOnly && chain.failureCount === 0) return false; if (!needle) return true; if (chain.episodeId?.toLowerCase().includes(needle)) return true; if (chain.sessionId?.toLowerCase().includes(needle)) return true; @@ -258,35 +305,41 @@ export function LogsView() {

{t("logs.subtitle")}

-
- + {detailedLogs && ( +
+ + +
+ )} + {detailedLogs && ( -
- + )} -