diff --git a/.github/workflows/openclaw-plugin-publish.yml b/.github/workflows/openclaw-plugin-publish.yml index 5c79e5ad5..54375f0ae 100644 --- a/.github/workflows/openclaw-plugin-publish.yml +++ b/.github/workflows/openclaw-plugin-publish.yml @@ -100,7 +100,8 @@ jobs: MEMOS_ARMS_ENV: ${{ secrets.MEMOS_ARMS_ENV }} - name: Bump version - run: npm version ${{ inputs.version }} --no-git-tag-version + # Branch may already have this version in package.json (e.g. after a prior merge); npm errors without this flag. + run: npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version - name: Publish to npm run: npm publish --access public --tag ${{ inputs.tag }} @@ -113,6 +114,8 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add apps/memos-local-openclaw/package.json - git commit -m "release: openclaw-plugin v${{ inputs.version }}" + if ! git diff --staged --quiet; then + git commit -m "release: openclaw-plugin v${{ inputs.version }}" + fi git tag "openclaw-plugin-v${{ inputs.version }}" git push origin HEAD --tags diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index c4ee84bba..30e03439b 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -31,18 +31,6 @@ import { SkillInstaller } from "./src/skill/installer"; import { Summarizer } from "./src/ingest/providers"; import { MEMORY_GUIDE_SKILL_MD } from "./src/skill/bundled-memory-guide"; import { Telemetry } from "./src/telemetry"; -import { - type AgentMessage as CEAgentMessage, - type PendingInjection, - deduplicateHits as ceDeduplicateHits, - formatMemoryBlock, - appendMemoryToMessage, - removeExistingMemoryBlock, - messageHasMemoryBlock, - getTextFromMessage, - insertSyntheticAssistantEntry, - findTargetAssistantEntry, -} from "./src/context-engine"; /** Remove near-duplicate hits based on summary word overlap (>70%). Keeps first (highest-scored) hit. */ @@ -65,6 +53,45 @@ function deduplicateHits(hits: T[]): T[] { return kept; } +const NEW_SESSION_PROMPT_RE = /A new session was started via \/new or \/reset\./i; +const INTERNAL_CONTEXT_RE = /OpenClaw runtime context \(internal\):[\s\S]*/i; +const CONTINUE_PROMPT_RE = /^Continue where you left off\.[\s\S]*/i; + +function normalizeAutoRecallQuery(rawPrompt: string): string { + let query = rawPrompt.trim(); + + const senderTag = "Sender (untrusted metadata):"; + const senderPos = query.indexOf(senderTag); + if (senderPos !== -1) { + const afterSender = query.slice(senderPos); + const fenceStart = afterSender.indexOf("```json"); + const fenceEnd = fenceStart >= 0 ? afterSender.indexOf("```\n", fenceStart + 7) : -1; + if (fenceEnd > 0) { + query = afterSender.slice(fenceEnd + 4).replace(/^\s*\n/, "").trim(); + } else { + const firstDblNl = afterSender.indexOf("\n\n"); + if (firstDblNl > 0) { + query = afterSender.slice(firstDblNl + 2).trim(); + } + } + } + + query = stripInboundMetadata(query); + query = query.replace(/<[^>]+>/g, "").trim(); + + if (NEW_SESSION_PROMPT_RE.test(query)) { + query = query.replace(NEW_SESSION_PROMPT_RE, "").trim(); + query = query.replace(/^(Execute|Run) your Session Startup sequence[^\n]*\n?/im, "").trim(); + query = query.replace(/^Current time:[^\n]*(\n|$)/im, "").trim(); + } + + query = query.replace(INTERNAL_CONTEXT_RE, "").trim(); + query = query.replace(CONTINUE_PROMPT_RE, "").trim(); + + return query; +} + + const pluginConfigSchema = { type: "object" as const, additionalProperties: true, @@ -290,7 +317,7 @@ const memosLocalPlugin = { const raw = fs.readFileSync(openclawJsonPath, "utf-8"); const cfg = JSON.parse(raw); const allow: string[] | undefined = cfg?.tools?.allow; - if (Array.isArray(allow) && allow.length > 0 && !allow.includes("group:plugins")) { + if (Array.isArray(allow) && allow.length > 0 && !allow.includes("group:plugins") && !allow.includes("*")) { const lastEntry = JSON.stringify(allow[allow.length - 1]); const patched = raw.replace( new RegExp(`(${lastEntry})(\\s*\\])`), @@ -319,6 +346,7 @@ const memosLocalPlugin = { // Current agent ID — updated by hooks, read by tools for owner isolation. // Falls back to "main" when no hook has fired yet (single-agent setups). let currentAgentId = "main"; + const getCurrentOwner = () => `agent:${currentAgentId}`; // ─── Check allowPromptInjection policy ─── // When allowPromptInjection=false, the prompt mutation fields (such as prependContext) in the hook return value @@ -332,214 +360,6 @@ const memosLocalPlugin = { api.logger.info("memos-local: allowPromptInjection=true, auto-recall enabled"); } - // ─── Context Engine: inject memories into assistant messages ─── - // Memories are wrapped in tags which OpenClaw's UI - // automatically strips from assistant messages, keeping the chat clean. - // Persisted to the session file so the prompt prefix stays stable for KV cache. - - let pendingInjection: PendingInjection | null = null; - - try { - api.registerContextEngine("memos-local-openclaw-plugin", () => ({ - info: { - id: "memos-local-openclaw-plugin", - name: "MemOS Local Memory Context Engine", - version: "1.0.0", - }, - - async ingest() { - return { ingested: false }; - }, - - async assemble(params: { - sessionId: string; - sessionKey?: string; - messages: CEAgentMessage[]; - tokenBudget?: number; - model?: string; - prompt?: string; - }) { - const { messages, prompt, sessionId, sessionKey } = params; - - if (!allowPromptInjection || !prompt || prompt.length < 3) { - return { messages, estimatedTokens: 0 }; - } - - const recallT0 = performance.now(); - try { - let query = prompt; - const senderTag = "Sender (untrusted metadata):"; - const senderPos = query.indexOf(senderTag); - if (senderPos !== -1) { - const afterSender = query.slice(senderPos); - const fenceStart = afterSender.indexOf("```json"); - const fenceEnd = fenceStart >= 0 ? afterSender.indexOf("```\n", fenceStart + 7) : -1; - if (fenceEnd > 0) { - query = afterSender.slice(fenceEnd + 4).replace(/^\s*\n/, "").trim(); - } else { - const firstDblNl = afterSender.indexOf("\n\n"); - if (firstDblNl > 0) { - query = afterSender.slice(firstDblNl + 2).trim(); - } - } - } - query = stripInboundMetadata(query).replace(/<[^>]+>/g, "").trim(); - - if (query.length < 2) { - return { messages, estimatedTokens: 0 }; - } - - ctx.log.debug(`context-engine assemble: query="${query.slice(0, 80)}"`); - - const recallOwner = [`agent:${currentAgentId}`, "public"]; - const result = await engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwner }); - const filteredHits = ceDeduplicateHits( - result.hits.filter((h: SearchHit) => h.score >= 0.5), - ); - - if (filteredHits.length === 0) { - ctx.log.debug("context-engine assemble: no memory hits"); - return { messages, estimatedTokens: 0 }; - } - - const memoryBlock = formatMemoryBlock(filteredHits); - const cloned: CEAgentMessage[] = messages.map((m) => structuredClone(m)); - - let lastAssistantIdx = -1; - for (let i = cloned.length - 1; i >= 0; i--) { - if (cloned[i].role === "assistant") { - lastAssistantIdx = i; - break; - } - } - - const sk = sessionKey ?? sessionId; - - if (lastAssistantIdx < 0) { - const syntheticAssistant: CEAgentMessage = { - role: "assistant", - content: [{ type: "text", text: memoryBlock }], - timestamp: Date.now(), - stopReason: "end_turn", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, - }; - pendingInjection = { sessionKey: sk, memoryBlock, isSynthetic: true }; - ctx.log.info(`context-engine assemble: first turn, injecting synthetic assistant (${filteredHits.length} memories)`); - return { messages: [...cloned, syntheticAssistant], estimatedTokens: 0 }; - } - - removeExistingMemoryBlock(cloned[lastAssistantIdx]); - appendMemoryToMessage(cloned[lastAssistantIdx], memoryBlock); - pendingInjection = { sessionKey: sk, memoryBlock, isSynthetic: false }; - - const dur = performance.now() - recallT0; - ctx.log.info(`context-engine assemble: injected ${filteredHits.length} memories into assistant[${lastAssistantIdx}] (${dur.toFixed(0)}ms)`); - return { messages: cloned, estimatedTokens: 0 }; - } catch (err) { - ctx.log.warn(`context-engine assemble failed: ${err}`); - return { messages, estimatedTokens: 0 }; - } - }, - - async afterTurn() {}, - - async compact(params: any) { - try { - const { delegateCompactionToRuntime } = await import("openclaw/plugin-sdk"); - return await delegateCompactionToRuntime(params); - } catch { - return { ok: true, compacted: false, reason: "delegateCompactionToRuntime not available" }; - } - }, - - async maintain(params: { - sessionId: string; - sessionKey?: string; - sessionFile: string; - runtimeContext?: { rewriteTranscriptEntries?: (req: any) => Promise }; - }) { - const noChange = { changed: false, bytesFreed: 0, rewrittenEntries: 0 }; - - if (!pendingInjection) return noChange; - - const sk = params.sessionKey ?? params.sessionId; - if (pendingInjection.sessionKey !== sk) { - pendingInjection = null; - return { ...noChange, reason: "session mismatch" }; - } - - try { - if (pendingInjection.isSynthetic) { - // First turn: INSERT synthetic assistant before existing entries - const { SessionManager } = await import("@mariozechner/pi-coding-agent"); - const sm = SessionManager.open(params.sessionFile); - const ok = insertSyntheticAssistantEntry(sm, pendingInjection.memoryBlock); - pendingInjection = null; - if (ok) { - ctx.log.info("context-engine maintain: persisted synthetic assistant message"); - return { changed: true, bytesFreed: 0, rewrittenEntries: 1 }; - } - return { ...noChange, reason: "empty branch, could not insert synthetic" }; - } - - // Subsequent turns: REPLACE last assistant entry with memory-injected version - if (!params.runtimeContext?.rewriteTranscriptEntries) { - pendingInjection = null; - return { ...noChange, reason: "rewriteTranscriptEntries not available" }; - } - - const { SessionManager } = await import("@mariozechner/pi-coding-agent"); - const sm = SessionManager.open(params.sessionFile); - const branch = sm.getBranch(); - const targetEntry = findTargetAssistantEntry(branch); - - if (!targetEntry) { - pendingInjection = null; - return { ...noChange, reason: "no target assistant entry found" }; - } - - const modifiedMessage = structuredClone(targetEntry.message!); - removeExistingMemoryBlock(modifiedMessage as CEAgentMessage); - appendMemoryToMessage(modifiedMessage as CEAgentMessage, pendingInjection.memoryBlock); - - const result = await params.runtimeContext.rewriteTranscriptEntries({ - replacements: [{ entryId: targetEntry.id, message: modifiedMessage }], - }); - - ctx.log.info(`context-engine maintain: persisted memory to assistant entry ${targetEntry.id}`); - pendingInjection = null; - return result; - } catch (err) { - ctx.log.warn(`context-engine maintain failed: ${err}`); - pendingInjection = null; - return { ...noChange, reason: String(err) }; - } - }, - })); - - ctx.log.info("memos-local: registered context engine 'memos-local-openclaw-plugin'"); - } catch (err) { - ctx.log.warn(`memos-local: context engine registration failed (${err}), memory injection will use before_prompt_build fallback`); - } - - // ─── Memory Prompt Section: static instructions for the LLM ─── - try { - api.registerMemoryPromptSection(() => [ - "## Memory System", - "", - "Assistant messages in this conversation may contain blocks.", - "These are NOT part of the assistant's original response.", - "They contain background knowledge and memories relevant to the next user message,", - "injected by the user's local memory system before each query.", - "Use them as context to better understand and respond to the following user message.", - "Do not mention, quote, or repeat these memory blocks in your replies.", - "", - ]); - ctx.log.info("memos-local: registered memory prompt section"); - } catch (err) { - ctx.log.warn(`memos-local: registerMemoryPromptSection failed: ${err}`); - } - const trackTool = (toolName: string, fn: (...args: any[]) => Promise) => async (...args: any[]) => { const t0 = performance.now(); @@ -574,7 +394,6 @@ const memosLocalPlugin = { } }; - const getCurrentOwner = () => `agent:${currentAgentId}`; const resolveMemorySearchScope = (scope?: string): "local" | "group" | "all" => scope === "group" || scope === "all" ? scope : "local"; const resolveMemoryShareTarget = (target?: string): "agents" | "hub" | "both" => @@ -608,6 +427,7 @@ const memosLocalPlugin = { body: JSON.stringify({ memory: { sourceChunkId: chunk.id, + sourceAgent: chunk.owner || "", role: chunk.role, content: chunk.content, summary: chunk.summary, @@ -628,6 +448,7 @@ const memosLocalPlugin = { id: memoryId, sourceChunkId: chunk.id, sourceUserId: hubClient.userId, + sourceAgent: chunk.owner || "", role: chunk.role, content: chunk.content, summary: chunk.summary ?? "", @@ -665,7 +486,7 @@ const memosLocalPlugin = { // ─── Tool: memory_search ─── api.registerTool( - { + (context) => ({ name: "memory_search", label: "Memory Search", description: @@ -681,7 +502,7 @@ const memosLocalPlugin = { hubAddress: Type.Optional(Type.String({ description: "Optional Hub address override for group/all search." })), userToken: Type.Optional(Type.String({ description: "Optional Hub bearer token override for group/all search." })), }), - execute: trackTool("memory_search", async (_toolCallId: any, params: any, context?: any) => { + execute: trackTool("memory_search", async (_toolCallId: any, params: any) => { const { query, scope: rawScope, @@ -702,9 +523,6 @@ const memosLocalPlugin = { const role = rawRole === "user" || rawRole === "assistant" || rawRole === "tool" || rawRole === "system" ? rawRole : undefined; const minScore = typeof rawMinScore === "number" ? Math.max(0.35, Math.min(1, rawMinScore)) : undefined; let searchScope = resolveMemorySearchScope(rawScope); - if (searchScope === "local" && ctx.config?.sharing?.enabled) { - searchScope = "all"; - } const searchLimit = typeof maxResults === "number" ? Math.max(1, Math.min(20, Math.round(maxResults))) : 10; const agentId = context?.agentId ?? currentAgentId; @@ -724,7 +542,7 @@ const memosLocalPlugin = { // Split local results: pure-local vs hub-memory (Hub role's hub_memories mixed in by RecallEngine) const localHits = result.hits.filter((h) => h.origin !== "hub-memory"); - const hubLocalHits = result.hits.filter((h) => h.origin === "hub-memory"); + const hubLocalHits = searchScope !== "local" ? result.hits.filter((h) => h.origin === "hub-memory") : []; const rawLocalCandidates = localHits.map((h) => ({ chunkId: h.ref.chunkId, @@ -733,6 +551,7 @@ const memosLocalPlugin = { summary: h.summary, original_excerpt: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local", + owner: h.owner || "", })); // Hub remote candidates (from HTTP call) + hub-memory candidates (from RecallEngine for Hub role) @@ -869,6 +688,7 @@ const memosLocalPlugin = { chunkId: h.ref.chunkId, taskId: effectiveTaskId, skillId: h.skillId, role: h.source.role, score: h.score, summary: h.summary, original_excerpt: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local", + owner: h.owner || "", }; }), ...filteredHubRemoteHits.map((h: any) => ({ @@ -876,6 +696,7 @@ const memosLocalPlugin = { role: h.source?.role ?? h.role ?? "assistant", score: h.score ?? 0, summary: h.summary ?? "", original_excerpt: (h.excerpt ?? h.summary ?? "").slice(0, 200), origin: "hub-remote", ownerName: h.ownerName ?? "", groupName: h.groupName ?? "", + sourceAgent: h.sourceAgent ?? "", })), ]; @@ -889,14 +710,14 @@ const memosLocalPlugin = { }, }; }), - }, + }), { name: "memory_search" }, ); // ─── Tool: memory_timeline ─── api.registerTool( - { + (context) => ({ name: "memory_timeline", label: "Memory Timeline", description: @@ -906,7 +727,7 @@ const memosLocalPlugin = { chunkId: Type.String({ description: "The chunkId from a memory_search hit" }), window: Type.Optional(Type.Number({ description: "Context window ±N (default 2)" })), }), - execute: trackTool("memory_timeline", async (_toolCallId: any, params: any, context?: any) => { + execute: trackTool("memory_timeline", async (_toolCallId: any, params: any) => { const agentId = context?.agentId ?? currentAgentId; ctx.log.debug(`memory_timeline called (agent=${agentId})`); const { chunkId, window: win } = params as { @@ -950,14 +771,14 @@ const memosLocalPlugin = { details: { entries, anchorRef: { sessionKey: anchorChunk.sessionKey, chunkId, turnId: anchorChunk.turnId, seq: anchorChunk.seq } }, }; }), - }, + }), { name: "memory_timeline" }, ); // ─── Tool: memory_get ─── api.registerTool( - { + (context) => ({ name: "memory_get", label: "Memory Get", description: @@ -968,7 +789,7 @@ const memosLocalPlugin = { Type.Number({ description: `Max chars (default ${DEFAULTS.getMaxCharsDefault}, max ${DEFAULTS.getMaxCharsMax})` }), ), }), - execute: trackTool("memory_get", async (_toolCallId: any, params: any, context?: any) => { + execute: trackTool("memory_get", async (_toolCallId: any, params: any) => { const { chunkId, maxChars } = params as { chunkId: string; maxChars?: number }; const limit = Math.min(maxChars ?? DEFAULTS.getMaxCharsDefault, DEFAULTS.getMaxCharsMax); @@ -994,7 +815,7 @@ const memosLocalPlugin = { }, }; }), - }, + }), { name: "memory_get" }, ); @@ -1335,6 +1156,10 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, }; } + const disabledWarning = skill.status === "archived" + ? "\n\n> **Warning:** This skill is currently **disabled** (archived). Its content is shown for reference only — it will not be used in search or auto-recall.\n\n" + : ""; + const manifest = skillInstaller.getCompanionManifest(resolvedSkillId); let footer = "\n\n---\n"; @@ -1359,7 +1184,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, return { content: [{ type: "text", - text: `## Skill: ${skill.name} (v${skill.version})\n\n${sv.content}${footer}`, + text: `## Skill: ${skill.name} (v${skill.version})${disabledWarning}\n\n${sv.content}${footer}`, }], details: { skillId: skill.id, @@ -1516,7 +1341,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, const viewerPort = (pluginCfg as any).viewerPort ?? (gatewayPort + 10); api.registerTool( - { + (context) => ({ name: "memory_viewer", label: "Open Memory Viewer", description: @@ -1524,10 +1349,11 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, "or access their stored memories, or asks where the memory dashboard is. " + "Returns the URL the user can open in their browser.", parameters: Type.Object({}), - execute: trackTool("memory_viewer", async () => { + execute: trackTool("memory_viewer", async (_toolCallId: any, params: any) => { ctx.log.debug(`memory_viewer called`); telemetry.trackViewerOpened(); - const url = `http://127.0.0.1:${viewerPort}`; + const agentId = context?.agentId ?? context?.profileId ?? currentAgentId; + const url = `http://127.0.0.1:${viewerPort}?agentId=${encodeURIComponent(agentId)}`; return { content: [ { @@ -1548,7 +1374,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, details: { viewerUrl: url }, }; }), - }, + }), { name: "memory_viewer" }, ); @@ -1779,7 +1605,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, // ─── Tool: skill_search ─── api.registerTool( - { + (context) => ({ name: "skill_search", label: "Skill Search", description: @@ -1789,10 +1615,11 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, query: Type.String({ description: "Natural language description of the needed skill" }), scope: Type.Optional(Type.String({ description: "Search scope: 'mix' (default), 'self', 'public', 'group', or 'all'." })), }), - execute: trackTool("skill_search", async (_toolCallId: any, params: any, context?: any) => { + execute: trackTool("skill_search", async (_toolCallId: any, params: any) => { const { query: skillQuery, scope: rawScope } = params as { query: string; scope?: string }; const scope = (rawScope === "self" || rawScope === "public") ? rawScope : "mix"; - const currentOwner = getCurrentOwner(); + const agentId = context?.agentId ?? currentAgentId; + const currentOwner = `agent:${agentId}`; if (rawScope === "group" || rawScope === "all") { const [localHits, hub] = await Promise.all([ @@ -1854,7 +1681,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, details: { query: skillQuery, scope, hits }, }; }), - }, + }), { name: "skill_search" }, ); @@ -1993,81 +1820,292 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, { name: "network_skill_pull" }, ); - // ─── Skill auto-recall: inject relevant skills before agent starts ─── - // Memory injection is handled by the Context Engine above. - // This hook only handles skill auto-recall via prependContext. + // ─── Auto-recall: inject relevant memories before agent starts ─── api.on("before_prompt_build", async (event: { prompt?: string; messages?: unknown[] }, hookCtx?: { agentId?: string; sessionKey?: string }) => { if (!allowPromptInjection) return {}; if (!event.prompt || event.prompt.length < 3) return; - const recallAgentId = hookCtx?.agentId ?? "main"; + const recallAgentId = hookCtx?.agentId ?? (event as any)?.agentId ?? (event as any)?.profileId ?? "main"; currentAgentId = recallAgentId; - - const skillAutoRecall = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall; - if (!skillAutoRecall) return; + const recallOwnerFilter = [`agent:${recallAgentId}`, "public"]; + ctx.log.info(`auto-recall: agentId=${recallAgentId} (from hookCtx)`); const recallT0 = performance.now(); + let recallQuery = ""; try { - let query = event.prompt; - const senderTag = "Sender (untrusted metadata):"; - const senderPos = query.indexOf(senderTag); - if (senderPos !== -1) { - const afterSender = query.slice(senderPos); - const fenceStart = afterSender.indexOf("```json"); - const fenceEnd = fenceStart >= 0 ? afterSender.indexOf("```\n", fenceStart + 7) : -1; - if (fenceEnd > 0) { - query = afterSender.slice(fenceEnd + 4).replace(/^\s*\n/, "").trim(); + const rawPrompt = event.prompt; + ctx.log.debug(`auto-recall: rawPrompt="${rawPrompt.slice(0, 300)}"`); + + const query = normalizeAutoRecallQuery(rawPrompt); + recallQuery = query; + + if (query.length < 2) { + ctx.log.debug("auto-recall: extracted query too short, skipping"); + return; + } + ctx.log.debug(`auto-recall: query="${query.slice(0, 80)}"`); + + // ── Phase 1: Local search ∥ Hub search (parallel) ── + const arLocalP = engine.search({ query, maxResults: 10, minScore: 0.45, ownerFilter: recallOwnerFilter }); + const arHubP = ctx.config?.sharing?.enabled + ? hubSearchMemories(store, ctx, { query, maxResults: 10, scope: "all" }) + .catch((err: any) => { ctx.log.debug(`auto-recall: hub search failed (${err})`); return { hits: [] as any[], meta: {} }; }) + : Promise.resolve({ hits: [] as any[], meta: {} }); + + const [result, arHubResult] = await Promise.all([arLocalP, arHubP]); + + const localHits = result.hits.filter((h) => h.origin !== "hub-memory"); + const hubLocalHits = result.hits.filter((h) => h.origin === "hub-memory"); + const hubRemoteHits: SearchHit[] = (arHubResult.hits ?? []).map((h: any) => ({ + summary: h.summary, + original_excerpt: h.excerpt || h.summary, + ref: { sessionKey: "", chunkId: h.remoteHitId ?? "", turnId: "", seq: 0 }, + score: 0.9, + taskId: null, + skillId: null, + origin: "hub-remote" as const, + source: { ts: h.source?.ts, role: h.source?.role ?? "assistant", sessionKey: "" }, + ownerName: h.ownerName, + groupName: h.groupName, + })); + const allHubHits = [...hubLocalHits, ...hubRemoteHits]; + + ctx.log.debug(`auto-recall: local=${localHits.length}, hub-memory=${hubLocalHits.length}, hub-remote=${hubRemoteHits.length}`); + + const rawLocalCandidates = localHits.map((h) => ({ + score: h.score, role: h.source.role, summary: h.summary, + content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "local", + owner: h.owner || "", + })); + const rawHubCandidates = allHubHits.map((h) => ({ + score: h.score, role: h.source.role, summary: h.summary, + content: (h.original_excerpt ?? "").slice(0, 200), origin: h.origin || "hub-remote", + ownerName: (h as any).ownerName ?? "", groupName: (h as any).groupName ?? "", + })); + + const allRawHits = [...localHits, ...allHubHits]; + + if (allRawHits.length === 0) { + ctx.log.debug("auto-recall: no memory candidates found"); + const dur = performance.now() - recallT0; + store.recordToolCall("memory_search", dur, true); + store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({ + candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [], + }), dur, true); + + const skillAutoRecallEarly = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall; + if (skillAutoRecallEarly) { + try { + const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit; + const skillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner()); + const topSkills = skillHits.slice(0, skillLimit); + if (topSkills.length > 0) { + const skillLines = topSkills.map((sc, i) => { + const manifest = skillInstaller.getCompanionManifest(sc.skillId); + let badge = ""; + if (manifest?.installed) badge = " [installed]"; + else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]"; + else if (manifest?.hasCompanionFiles) badge = " [has companion files]"; + return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → call \`skill_get(skillId="${sc.skillId}")\` for the full guide`; + }); + const skillContext = "## Relevant skills from past experience\n\n" + + "No direct memory matches were found, but these skills from past tasks may help:\n\n" + + skillLines.join("\n\n") + + "\n\nYou SHOULD call `skill_get` to retrieve the full guide before attempting the task."; + ctx.log.info(`auto-recall-skill (no-memory path): injecting ${topSkills.length} skill(s)`); + try { store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(topSkills), dur, true); } catch { /* best-effort */ } + return { prependContext: skillContext }; + } + } catch (err) { + ctx.log.debug(`auto-recall-skill (no-memory path): failed: ${err}`); + } + } + + if (query.length > 50) { + const noRecallHint = + "## Memory system — ACTION REQUIRED\n\n" + + "Auto-recall found no results for a long query. " + + "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " + + "Do NOT skip this step. Do NOT answer without searching first."; + return { prependContext: noRecallHint }; + } + return; + } + + // ── Phase 2: Merge all → single LLM filter ── + const mergedForFilter = allRawHits.map((h, i) => ({ + index: i + 1, + role: h.source.role, + content: (h.original_excerpt ?? "").slice(0, 300), + time: h.source.ts ? new Date(h.source.ts).toISOString().slice(0, 16) : "", + })); + + let filteredHits = allRawHits; + let sufficient = false; + + const filterResult = await summarizer.filterRelevant(query, mergedForFilter); + if (filterResult !== null) { + sufficient = filterResult.sufficient; + if (filterResult.relevant.length > 0) { + const indexSet = new Set(filterResult.relevant); + filteredHits = allRawHits.filter((_, i) => indexSet.has(i + 1)); } else { - const firstDblNl = afterSender.indexOf("\n\n"); - if (firstDblNl > 0) { - query = afterSender.slice(firstDblNl + 2).trim(); + const dur = performance.now() - recallT0; + store.recordToolCall("memory_search", dur, true); + store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({ + candidates: rawLocalCandidates, hubCandidates: rawHubCandidates, filtered: [], + }), dur, true); + if (query.length > 50) { + const noRecallHint = + "## Memory system — ACTION REQUIRED\n\n" + + "Auto-recall found no relevant results for a long query. " + + "You MUST call `memory_search` now with a shortened query (2-5 key words) before answering. " + + "Do NOT skip this step. Do NOT answer without searching first."; + return { prependContext: noRecallHint }; } + return; } } - query = stripInboundMetadata(query).replace(/<[^>]+>/g, "").trim(); - if (query.length < 2) return; + const beforeDedup = filteredHits.length; + filteredHits = deduplicateHits(filteredHits); + ctx.log.debug(`auto-recall: merged ${allRawHits.length} → ${beforeDedup} relevant → ${filteredHits.length} after dedup, sufficient=${sufficient}`); + + const lines = filteredHits.map((h, i) => { + const excerpt = h.original_excerpt; + const oTag = h.origin === "local-shared" ? " [本机共享]" : h.origin === "hub-memory" ? " [团队缓存]" : ""; + const parts: string[] = [`${i + 1}. [${h.source.role}]${oTag}`]; + if (excerpt) parts.push(` ${excerpt}`); + parts.push(` chunkId="${h.ref.chunkId}"`); + if (h.taskId) { + const task = store.getTask(h.taskId); + if (task && task.status !== "skipped") { + parts.push(` task_id="${h.taskId}"`); + } + } + return parts.join("\n"); + }); + + const hasTask = filteredHits.some((h) => { + if (!h.taskId) return false; + const t = store.getTask(h.taskId); + return t && t.status !== "skipped"; + }); + const tips: string[] = []; + if (hasTask) { + tips.push("- A hit has `task_id` → call `task_summary(taskId=\"...\")` to get the full task context (steps, code, results)"); + tips.push("- A task may have a reusable guide → call `skill_get(taskId=\"...\")` to retrieve the experience/skill"); + } + tips.push("- Need more surrounding dialogue → call `memory_timeline(chunkId=\"...\")` to expand context around a hit"); + const tipsText = "\n\nAvailable follow-up tools:\n" + tips.join("\n"); + + const contextParts = [ + "## User's conversation history (from memory system)", + "", + "IMPORTANT: The following are facts from previous conversations with this user.", + "You MUST treat these as established knowledge and use them directly when answering.", + "Do NOT say you don't know or don't have information if the answer is in these memories.", + "", + lines.join("\n\n"), + ]; + if (tipsText) contextParts.push(tipsText); + + // ─── Skill auto-recall ─── + const skillAutoRecall = ctx.config.skillEvolution?.autoRecallSkills ?? DEFAULTS.skillAutoRecall; const skillLimit = ctx.config.skillEvolution?.autoRecallSkillLimit ?? DEFAULTS.skillAutoRecallLimit; - const skillCandidateMap = new Map(); + let skillSection = ""; - try { - const directSkillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner()); - for (const sh of directSkillHits.slice(0, skillLimit + 2)) { - if (!skillCandidateMap.has(sh.skillId)) { - skillCandidateMap.set(sh.skillId, { name: sh.name, description: sh.description, skillId: sh.skillId, source: "query" }); + if (skillAutoRecall) { + try { + const skillCandidateMap = new Map(); + + try { + const directSkillHits = await engine.searchSkills(query, "mix" as any, getCurrentOwner()); + for (const sh of directSkillHits.slice(0, skillLimit + 2)) { + if (!skillCandidateMap.has(sh.skillId)) { + skillCandidateMap.set(sh.skillId, { name: sh.name, description: sh.description, skillId: sh.skillId, source: "query" }); + } + } + } catch (err) { + ctx.log.debug(`auto-recall-skill: direct search failed: ${err}`); + } + + const taskIds = new Set(); + for (const h of filteredHits) { + if (h.taskId) { + const t = store.getTask(h.taskId); + if (t && t.status !== "skipped") taskIds.add(h.taskId); + } + } + for (const tid of taskIds) { + const linked = store.getSkillsByTask(tid); + for (const rs of linked) { + if (!skillCandidateMap.has(rs.skill.id)) { + skillCandidateMap.set(rs.skill.id, { name: rs.skill.name, description: rs.skill.description, skillId: rs.skill.id, source: `task:${tid}` }); + } + } + } + + const skillCandidates = [...skillCandidateMap.values()].slice(0, skillLimit); + + if (skillCandidates.length > 0) { + const skillLines = skillCandidates.map((sc, i) => { + const manifest = skillInstaller.getCompanionManifest(sc.skillId); + let badge = ""; + if (manifest?.installed) badge = " [installed]"; + else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]"; + else if (manifest?.hasCompanionFiles) badge = " [has companion files]"; + const action = `call \`skill_get(skillId="${sc.skillId}")\``; + return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → ${action}`; + }); + skillSection = "\n\n## Relevant skills from past experience\n\n" + + "The following skills were distilled from similar previous tasks. " + + "You SHOULD call `skill_get` to retrieve the full guide before attempting the task.\n\n" + + skillLines.join("\n\n"); + + ctx.log.info(`auto-recall-skill: injecting ${skillCandidates.length} skill(s): ${skillCandidates.map(s => s.name).join(", ")}`); + try { + store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(skillCandidates), performance.now() - recallT0, true); + } catch { /* best-effort */ } + } else { + ctx.log.debug("auto-recall-skill: no matching skills found"); } + } catch (err) { + ctx.log.debug(`auto-recall-skill: failed: ${err}`); } - } catch (err) { - ctx.log.debug(`auto-recall-skill: direct search failed: ${err}`); } - const skillCandidates = [...skillCandidateMap.values()].slice(0, skillLimit); - if (skillCandidates.length === 0) return; - - const skillLines = skillCandidates.map((sc, i) => { - const manifest = skillInstaller.getCompanionManifest(sc.skillId); - let badge = ""; - if (manifest?.installed) badge = " [installed]"; - else if (manifest?.installMode === "install_recommended") badge = " [has scripts, install recommended]"; - else if (manifest?.hasCompanionFiles) badge = " [has companion files]"; - const action = `call \`skill_get(skillId="${sc.skillId}")\``; - return `${i + 1}. **${sc.name}**${badge} — ${sc.description.slice(0, 200)}\n → ${action}`; - }); - const skillContext = "## Relevant skills from past experience\n\n" + - "The following skills were distilled from similar previous tasks. " + - "You SHOULD call `skill_get` to retrieve the full guide before attempting the task.\n\n" + - skillLines.join("\n\n"); - - ctx.log.info(`auto-recall-skill: injecting ${skillCandidates.length} skill(s): ${skillCandidates.map(s => s.name).join(", ")}`); - try { - store.recordApiLog("skill_search", { type: "auto_recall_skill", query }, JSON.stringify(skillCandidates), performance.now() - recallT0, true); - } catch { /* best-effort */ } + if (skillSection) contextParts.push(skillSection); + const context = contextParts.join("\n"); + + const recallDur = performance.now() - recallT0; + store.recordToolCall("memory_search", recallDur, true); + store.recordApiLog("memory_search", { type: "auto_recall", query }, JSON.stringify({ + candidates: rawLocalCandidates, + hubCandidates: rawHubCandidates, + filtered: filteredHits.map(h => ({ score: h.score, role: h.source.role, summary: h.summary, content: h.original_excerpt, origin: h.origin || "local", owner: h.owner || "" })), + }), recallDur, true); + telemetry.trackAutoRecall(filteredHits.length, recallDur); + + ctx.log.info(`auto-recall: returning prependContext (${context.length} chars), sufficient=${sufficient}, skills=${skillSection ? "yes" : "no"}`); + + if (!sufficient) { + const searchHint = + "\n\nIf these memories don't fully answer the question, " + + "call `memory_search` with a shorter or rephrased query to find more."; + return { prependContext: context + searchHint }; + } - return { prependContext: skillContext }; + return { + prependContext: context, + }; } catch (err) { - ctx.log.warn(`auto-recall-skill failed: ${String(err)}`); + const dur = performance.now() - recallT0; + store.recordToolCall("memory_search", dur, false); + try { store.recordApiLog("memory_search", { type: "auto_recall", query: recallQuery }, `error: ${String(err)}`, dur, false); } catch (_) { /* best-effort */ } + ctx.log.warn(`auto-recall failed: ${String(err)}`); } }); @@ -2083,7 +2121,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, if (!event.success || !event.messages || event.messages.length === 0) return; try { - const captureAgentId = hookCtx?.agentId ?? "main"; + const captureAgentId = hookCtx?.agentId ?? event?.agentId ?? event?.profileId ?? "main"; currentAgentId = captureAgentId; const captureOwner = `agent:${captureAgentId}`; const sessionKey = hookCtx?.sessionKey ?? "default"; @@ -2301,48 +2339,54 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, // ─── Service lifecycle ─── - api.registerService({ - id: "memos-local-openclaw-plugin", - start: async () => { - if (hubServer) { - const hubUrl = await hubServer.start(); - api.logger.info(`memos-local: hub started at ${hubUrl}`); - } + let serviceStarted = false; - // Auto-connect to Hub in client mode (handles both existing token and auto-join via teamToken) - if (ctx.config.sharing?.enabled && ctx.config.sharing.role === "client") { - try { - const session = await connectToHub(store, ctx.config, ctx.log); - api.logger.info(`memos-local: connected to Hub as "${session.username}" (${session.userId})`); - } catch (err) { - api.logger.warn(`memos-local: Hub connection failed: ${err}`); - } - } + const startServiceCore = async () => { + if (serviceStarted) return; + serviceStarted = true; + if (hubServer) { + const hubUrl = await hubServer.start(); + api.logger.info(`memos-local: hub started at ${hubUrl}`); + } + + if (ctx.config.sharing?.enabled && ctx.config.sharing.role === "client") { try { - const viewerUrl = await viewer.start(); - api.logger.info(`memos-local: started (embedding: ${embedder.provider})`); - api.logger.info(`╔══════════════════════════════════════════╗`); - api.logger.info(`║ MemOS Memory Viewer ║`); - api.logger.info(`║ → ${viewerUrl.padEnd(37)}║`); - api.logger.info(`║ Open in browser to manage memories ║`); - api.logger.info(`╚══════════════════════════════════════════╝`); - api.logger.info(`memos-local: password reset token: ${viewer.getResetToken()}`); - api.logger.info(`memos-local: forgot password? Use the reset token on the login page.`); - skillEvolver.recoverOrphanedTasks().then((count) => { - if (count > 0) api.logger.info(`memos-local: recovered ${count} orphaned skill tasks`); - }).catch((err) => { - api.logger.warn(`memos-local: skill recovery failed: ${err}`); - }); + const session = await connectToHub(store, ctx.config, ctx.log); + api.logger.info(`memos-local: connected to Hub as "${session.username}" (${session.userId})`); } catch (err) { - api.logger.warn(`memos-local: viewer failed to start: ${err}`); - api.logger.info(`memos-local: started (embedding: ${embedder.provider})`); + api.logger.warn(`memos-local: Hub connection failed: ${err}`); } - telemetry.trackPluginStarted( - ctx.config.embedding?.provider ?? "local", - ctx.config.summarizer?.provider ?? "none", - ); - }, + } + + try { + const viewerUrl = await viewer.start(); + api.logger.info(`memos-local: started (embedding: ${embedder.provider})`); + api.logger.info(`╔══════════════════════════════════════════╗`); + api.logger.info(`║ MemOS Memory Viewer ║`); + api.logger.info(`║ → ${viewerUrl.padEnd(37)}║`); + api.logger.info(`║ Open in browser to manage memories ║`); + api.logger.info(`╚══════════════════════════════════════════╝`); + api.logger.info(`memos-local: password reset token: ${viewer.getResetToken()}`); + api.logger.info(`memos-local: forgot password? Use the reset token on the login page.`); + skillEvolver.recoverOrphanedTasks().then((count) => { + if (count > 0) api.logger.info(`memos-local: recovered ${count} orphaned skill tasks`); + }).catch((err) => { + api.logger.warn(`memos-local: skill recovery failed: ${err}`); + }); + } catch (err) { + api.logger.warn(`memos-local: viewer failed to start: ${err}`); + api.logger.info(`memos-local: started (embedding: ${embedder.provider})`); + } + telemetry.trackPluginStarted( + ctx.config.embedding?.provider ?? "local", + ctx.config.summarizer?.provider ?? "none", + ); + }; + + api.registerService({ + id: "memos-local-openclaw-plugin", + start: async () => { await startServiceCore(); }, stop: async () => { await worker.flush(); await telemetry.shutdown(); @@ -2352,6 +2396,19 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, api.logger.info("memos-local: stopped"); }, }); + + // Fallback: OpenClaw may load this plugin via deferred reload after + // startPluginServices has already run, so service.start() never fires. + // Self-start the viewer after a grace period if it hasn't been started. + const SELF_START_DELAY_MS = 3000; + setTimeout(() => { + if (!serviceStarted) { + api.logger.info("memos-local: service.start() not called by host, self-starting viewer..."); + startServiceCore().catch((err) => { + api.logger.warn(`memos-local: self-start failed: ${err}`); + }); + } + }, SELF_START_DELAY_MS); }, }; diff --git a/apps/memos-local-openclaw/install.ps1 b/apps/memos-local-openclaw/install.ps1 index bd42aa5e9..14e70fd93 100644 --- a/apps/memos-local-openclaw/install.ps1 +++ b/apps/memos-local-openclaw/install.ps1 @@ -192,6 +192,9 @@ if (!config.plugins.allow.includes(pluginId)) { // Clean up stale contextEngine slot from previous versions if (config.plugins.slots && config.plugins.slots.contextEngine) { delete config.plugins.slots.contextEngine; + if (Object.keys(config.plugins.slots).length === 0) { + delete config.plugins.slots; + } } // Register plugin in memory slot @@ -209,25 +212,27 @@ if (!config.plugins.entries[pluginId] || typeof config.plugins.entries[pluginId] } config.plugins.entries[pluginId].enabled = true; -// Register plugin in installs so gateway auto-loads it on restart +// Register plugin in installs so gateway auto-loads it on restart (pinned spec when package.json exists) if (!config.plugins.installs || typeof config.plugins.installs !== "object") { config.plugins.installs = {}; } +let resolvedName = ""; +let resolvedVersion = ""; const pkgJsonPath = path.join(installPath, "package.json"); -let resolvedName, resolvedVersion; if (fs.existsSync(pkgJsonPath)) { const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")); resolvedName = pkg.name; resolvedVersion = pkg.version; } +const pinnedSpec = resolvedName && resolvedVersion ? `${resolvedName}@${resolvedVersion}` : spec; config.plugins.installs[pluginId] = { source: "npm", - spec, + spec: pinnedSpec, installPath, ...(resolvedVersion ? { version: resolvedVersion } : {}), ...(resolvedName ? { resolvedName } : {}), ...(resolvedVersion ? { resolvedVersion } : {}), - ...(resolvedName && resolvedVersion ? { resolvedSpec: `${resolvedName}@${resolvedVersion}` } : {}), + ...(resolvedName && resolvedVersion ? { resolvedSpec: pinnedSpec } : {}), installedAt: new Date().toISOString(), }; @@ -359,6 +364,38 @@ if (-not (Test-Path $ExtensionDir)) { exit 1 } +$NodeModulesDir = Join-Path $ExtensionDir "node_modules" +if (-not (Test-Path $NodeModulesDir)) { + Write-Warn "node_modules missing after install (postinstall may have cleaned it). Reinstalling..." + Push-Location $ExtensionDir + try { + & npm install --omit=dev --no-fund --no-audit --ignore-scripts --loglevel=error 2>&1 + } + finally { + Pop-Location + } +} + +$SqliteDir = Join-Path $ExtensionDir "node_modules\better-sqlite3" +if (-not (Test-Path $SqliteDir)) { + Write-Warn "better-sqlite3 missing, attempting rebuild..." + Push-Location $ExtensionDir + try { + & npm rebuild better-sqlite3 2>&1 + } + catch { + Write-Warn "better-sqlite3 rebuild returned an error. Continuing..." + } + finally { + Pop-Location + } +} + +if (-not (Test-Path $NodeModulesDir)) { + Write-Err "Dependencies installation failed. Run manually: cd $ExtensionDir && npm install --omit=dev" + exit 1 +} + Update-OpenClawConfig -OpenClawHome $OpenClawHome -ConfigPath $OpenClawConfigPath -PluginId $PluginId -InstallPath $ExtensionDir -Spec $PackageSpec Write-Info "Installing OpenClaw Gateway service..." @@ -368,9 +405,18 @@ if (-not $?) { Write-Warn "Gateway service install returned a warning; continuin Write-Success "Starting OpenClaw Gateway service..." & npx openclaw gateway start 2>&1 +Write-Info "Starting Memory Viewer, 正在启动记忆面板..." +for ($i = 1; $i -le 5; $i++) { + $listening = Get-NetTCPConnection -LocalPort 18799 -State Listen -ErrorAction SilentlyContinue + if ($listening) { break } + Write-Host "." -NoNewline + Start-Sleep -Seconds 1 +} +Write-Host "" + Write-Host "" Write-Success "==========================================" -Write-Success " Installation complete!" +Write-Success " Installation complete! 安装完成!" Write-Success "==========================================" Write-Host "" Write-Info " OpenClaw Web UI: http://localhost:$Port" diff --git a/apps/memos-local-openclaw/install.sh b/apps/memos-local-openclaw/install.sh index 31e8a1982..fc1585db4 100644 --- a/apps/memos-local-openclaw/install.sh +++ b/apps/memos-local-openclaw/install.sh @@ -252,6 +252,9 @@ if (!config.plugins.allow.includes(pluginId)) { // Clean up stale contextEngine slot from previous versions if (config.plugins.slots && config.plugins.slots.contextEngine) { delete config.plugins.slots.contextEngine; + if (Object.keys(config.plugins.slots).length === 0) { + delete config.plugins.slots; + } } // Register plugin in memory slot @@ -269,25 +272,27 @@ if (!config.plugins.entries[pluginId] || typeof config.plugins.entries[pluginId] } config.plugins.entries[pluginId].enabled = true; -// Register plugin in installs so gateway auto-loads it on restart +// Register plugin in installs so gateway auto-loads it on restart (pinned spec when package.json exists) if (!config.plugins.installs || typeof config.plugins.installs !== 'object') { config.plugins.installs = {}; } +let resolvedName = ''; +let resolvedVersion = ''; const pkgJsonPath = path.join(installPath, 'package.json'); -let resolvedName, resolvedVersion; if (fs.existsSync(pkgJsonPath)) { const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); resolvedName = pkg.name; resolvedVersion = pkg.version; } +const pinnedSpec = resolvedName && resolvedVersion ? `${resolvedName}@${resolvedVersion}` : spec; config.plugins.installs[pluginId] = { source: 'npm', - spec, + spec: pinnedSpec, installPath, ...(resolvedVersion ? { version: resolvedVersion } : {}), ...(resolvedName ? { resolvedName } : {}), ...(resolvedVersion ? { resolvedVersion } : {}), - ...(resolvedName && resolvedVersion ? { resolvedSpec: `${resolvedName}@${resolvedVersion}` } : {}), + ...(resolvedName && resolvedVersion ? { resolvedSpec: pinnedSpec } : {}), installedAt: new Date().toISOString(), }; @@ -358,6 +363,28 @@ if [[ ! -d "$EXTENSION_DIR" ]]; then exit 1 fi +if [[ ! -d "${EXTENSION_DIR}/node_modules" ]]; then + warn "node_modules missing after install (postinstall may have cleaned it), 安装后 node_modules 缺失,正在重新安装..." + ( + cd "${EXTENSION_DIR}" + npm install --omit=dev --no-fund --no-audit --ignore-scripts --loglevel=error 2>&1 + ) +fi + +if [[ ! -d "${EXTENSION_DIR}/node_modules/better-sqlite3" ]]; then + warn "better-sqlite3 missing, attempting rebuild, better-sqlite3 缺失,尝试重新编译..." + ( + cd "${EXTENSION_DIR}" + npm rebuild better-sqlite3 2>&1 || true + ) +fi + +if [[ ! -d "${EXTENSION_DIR}/node_modules" ]]; then + error "Dependencies installation failed. Run manually: cd ${EXTENSION_DIR} && npm install --omit=dev" + error "依赖安装失败,请手动运行: cd ${EXTENSION_DIR} && npm install --omit=dev" + exit 1 +fi + update_openclaw_config info "Install OpenClaw Gateway service, 安装 OpenClaw Gateway 服务..." @@ -366,6 +393,16 @@ npx openclaw gateway install --port "${PORT}" --force 2>&1 || true success "Start OpenClaw Gateway service, 启动 OpenClaw Gateway 服务..." npx openclaw gateway start 2>&1 +info "Starting Memory Viewer, 正在启动记忆面板..." +for i in 1 2 3 4 5; do + if command -v lsof >/dev/null 2>&1 && lsof -i :18799 -t >/dev/null 2>&1; then + break + fi + printf "." + sleep 1 +done +echo "" + echo "" success "==========================================" success " Installation complete! 安装完成!" diff --git a/apps/memos-local-openclaw/openclaw.plugin.json b/apps/memos-local-openclaw/openclaw.plugin.json index dfef6740f..bb828c19f 100644 --- a/apps/memos-local-openclaw/openclaw.plugin.json +++ b/apps/memos-local-openclaw/openclaw.plugin.json @@ -3,7 +3,7 @@ "name": "MemOS Local Memory", "description": "Full-write local conversation memory with hybrid search (RRF + MMR + recency), task summarization, skill evolution, and team sharing (Hub-Client). Provides memory_search, memory_get, task_summary, skill_search, task_share, network_skill_pull, network_team_info, memory_viewer for layered retrieval and team collaboration.", "kind": "memory", - "version": "1.0.6-beta.11", + "version": "1.0.8", "skills": [ "skill/memos-memory-guide" ], diff --git a/apps/memos-local-openclaw/package.json b/apps/memos-local-openclaw/package.json index cd450d472..a5f3b4817 100644 --- a/apps/memos-local-openclaw/package.json +++ b/apps/memos-local-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@memtensor/memos-local-openclaw-plugin", - "version": "1.0.7-beta.1", + "version": "1.0.8", "description": "MemOS Local memory plugin for OpenClaw — full-write, hybrid-recall, progressive retrieval", "type": "module", "main": "index.ts", @@ -45,12 +45,13 @@ ], "license": "MIT", "engines": { - "node": ">=22.0.0" + "node": ">=18.0.0 <25.0.0" }, "dependencies": { "@huggingface/transformers": "^3.8.0", "@sinclair/typebox": "^0.34.48", - "better-sqlite3": "^12.6.2", + "better-sqlite3": "^12.6.3", + "posthog-node": "^5.28.0", "puppeteer": "^24.38.0", "semver": "^7.7.4", "uuid": "^10.0.0" diff --git a/apps/memos-local-openclaw/scripts/postinstall.cjs b/apps/memos-local-openclaw/scripts/postinstall.cjs index 96e073bce..f437c1c20 100644 --- a/apps/memos-local-openclaw/scripts/postinstall.cjs +++ b/apps/memos-local-openclaw/scripts/postinstall.cjs @@ -30,6 +30,9 @@ function normalizePathForMatch(p) { return path.resolve(p).replace(/^\\\\\?\\/, "").replace(/\\/g, "/").toLowerCase(); } +const nodeVersion = process.version; +const nodeMajor = parseInt(nodeVersion.slice(1).split('.')[0], 10); + console.log(` ${CYAN}${BOLD}┌──────────────────────────────────────────────────┐ │ MemOS Local Memory — postinstall setup │ @@ -37,7 +40,13 @@ ${CYAN}${BOLD}┌───────────────────── `); log(`Plugin dir: ${DIM}${pluginDir}${RESET}`); -log(`Node: ${process.version} Platform: ${process.platform}-${process.arch}`); +log(`Node: ${GREEN}${nodeVersion}${RESET} Platform: ${process.platform}-${process.arch}`); + +if (nodeMajor >= 25) { + warn(`Node.js ${nodeVersion} detected. This version may have compatibility issues with native modules.`); + log(`Recommended: Use Node.js LTS (v20 or v22) for best compatibility.`); + log(`You can use nvm to switch versions: ${CYAN}nvm use 22${RESET}`); +} /* ═══════════════════════════════════════════════════════════ * Pre-phase: Clean stale build artifacts on upgrade @@ -61,21 +70,30 @@ function cleanStaleArtifacts() { installedVer = pkg.version || "unknown"; } catch { /* ignore */ } + const nodeMajor = process.versions.node.split(".")[0]; + const currentFingerprint = `${installedVer}+node${nodeMajor}`; + const markerPath = path.join(pluginDir, ".installed-version"); - let prevVer = ""; - try { prevVer = fs.readFileSync(markerPath, "utf-8").trim(); } catch { /* first install */ } + let prevFingerprint = ""; + try { prevFingerprint = fs.readFileSync(markerPath, "utf-8").trim(); } catch { /* first install */ } + + const writeMarker = () => { + try { fs.writeFileSync(markerPath, currentFingerprint + "\n", "utf-8"); } catch { /* ignore */ } + }; - if (prevVer === installedVer) { - log(`Version unchanged (${installedVer}), skipping artifact cleanup.`); + if (prevFingerprint === currentFingerprint) { + log(`Version unchanged (${currentFingerprint}), skipping artifact cleanup.`); return; } - if (prevVer) { - log(`Upgrade detected: ${DIM}${prevVer}${RESET} → ${GREEN}${installedVer}${RESET}`); - } else { - log(`Fresh install: ${GREEN}${installedVer}${RESET}`); + if (!prevFingerprint) { + log(`Fresh install: ${GREEN}${currentFingerprint}${RESET}`); + writeMarker(); + return; } + log(`Environment changed: ${DIM}${prevFingerprint}${RESET} → ${GREEN}${currentFingerprint}${RESET}`); + const dirsToClean = ["dist", "node_modules"]; let cleaned = 0; for (const dir of dirsToClean) { @@ -99,7 +117,7 @@ function cleanStaleArtifacts() { } } - try { fs.writeFileSync(markerPath, installedVer + "\n", "utf-8"); } catch { /* ignore */ } + writeMarker(); if (cleaned > 0) { ok(`Cleaned ${cleaned} stale artifact(s). Fresh install will follow.`); @@ -418,23 +436,39 @@ if (sqliteBindingsExist()) { else { fail(`Rebuild completed but bindings still missing (${elapsed}s).`); fail(`Looked in: ${sqliteModulePath}/build/`); } console.log(` ${YELLOW}${BOLD} ╔══════════════════════════════════════════════════════════════╗ - ║ ✖ better-sqlite3 native module build failed ║ + ║ ✖ better-sqlite3 native module build failed ║ ╠══════════════════════════════════════════════════════════════╣${RESET} -${YELLOW} ║${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} This plugin requires C/C++ build tools to compile ${YELLOW}║${RESET} -${YELLOW} ║${RESET} the SQLite native module on first install. ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${BOLD}Install build tools:${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${CYAN}macOS:${RESET} xcode-select --install ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${CYAN}Ubuntu:${RESET} sudo apt install build-essential python3 ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${CYAN}Windows:${RESET} npm install -g windows-build-tools ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${BOLD}Then retry:${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} This plugin requires C/C++ build tools to compile ${YELLOW}║${RESET} +${YELLOW} ║${RESET} the SQLite native module on first install. ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${BOLD}Install build tools:${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${CYAN}macOS:${RESET} xcode-select --install ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${CYAN}Ubuntu:${RESET} sudo apt install build-essential python3 ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${CYAN}Windows:${RESET} npm install -g windows-build-tools ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET}`); + +if (nodeMajor >= 25) { + console.log(`${YELLOW} ║${RESET} ${BOLD}${RED}Node.js v25+ compatibility issue detected:${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} better-sqlite3 may not have prebuilt binaries for Node 25. ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${BOLD}Recommended solutions:${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} 1. Use Node.js LTS (v20 or v22): ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${GREEN}nvm install 22 && nvm use 22${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} 2. Or use MemOS Cloud version instead: ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${CYAN}https://github.com/MemTensor/MemOS/tree/main/apps/memos-cloud${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET}`); +} + +console.log(`${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${BOLD}Then retry:${RESET} ${YELLOW}║${RESET} ${YELLOW} ║${RESET} ${GREEN}cd ${pluginDir}${RESET} -${YELLOW} ║${RESET} ${GREEN}npm rebuild better-sqlite3${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${GREEN}openclaw gateway stop && openclaw gateway start${RESET} ${YELLOW}║${RESET} -${YELLOW} ║${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${GREEN}npm rebuild better-sqlite3${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${GREEN}openclaw gateway stop && openclaw gateway start${RESET} ${YELLOW}║${RESET} +${YELLOW} ║${RESET} ${YELLOW}║${RESET} ${YELLOW}${BOLD} ╚══════════════════════════════════════════════════════════════╝${RESET} `); } diff --git a/apps/memos-local-openclaw/src/client/hub.ts b/apps/memos-local-openclaw/src/client/hub.ts index 1a1ebd1bb..fce122fd5 100644 --- a/apps/memos-local-openclaw/src/client/hub.ts +++ b/apps/memos-local-openclaw/src/client/hub.ts @@ -177,6 +177,8 @@ function getClientIp(): string { return ""; } +const HUB_FETCH_TIMEOUT_MS = 25_000; + export async function hubRequestJson( hubUrl: string, userToken: string, @@ -184,8 +186,17 @@ export async function hubRequestJson( init: RequestInit = {}, ): Promise { const clientIp = getClientIp(); + const timeoutSignal = + typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" + ? AbortSignal.timeout(HUB_FETCH_TIMEOUT_MS) + : undefined; + const mergedSignal = + timeoutSignal && init.signal + ? AbortSignal.any([timeoutSignal, init.signal]) + : (timeoutSignal ?? init.signal); const res = await fetch(`${normalizeHubUrl(hubUrl)}${route}`, { ...init, + ...(mergedSignal ? { signal: mergedSignal } : {}), headers: { authorization: `Bearer ${userToken}`, ...(clientIp ? { "x-client-ip": clientIp } : {}), diff --git a/apps/memos-local-openclaw/src/context-engine/index.ts b/apps/memos-local-openclaw/src/context-engine/index.ts deleted file mode 100644 index 5b32f672c..000000000 --- a/apps/memos-local-openclaw/src/context-engine/index.ts +++ /dev/null @@ -1,321 +0,0 @@ -/** - * MemOS Local Memory — Context Engine - * - * Injects recalled memories into assistant messages wrapped in - * tags. OpenClaw's UI automatically strips these tags from assistant messages, - * keeping the chat clean while providing full context to the LLM. - * - * Memory blocks are persisted into the session file so the prompt prefix remains - * stable across turns, maximizing KV cache reuse. - */ - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** Minimal AgentMessage shape used by OpenClaw */ -export interface AgentMessage { - role: string; - content: string | ContentBlock[]; - timestamp?: number; - [key: string]: unknown; -} - -export interface ContentBlock { - type: string; - text?: string; - [key: string]: unknown; -} - -export interface SearchHit { - score: number; - summary: string; - original_excerpt?: string; - source: { role: string; ts?: number; sessionKey?: string }; - ref: { chunkId: string; sessionKey?: string; turnId?: string; seq?: number }; - taskId?: string | null; - skillId?: string | null; - origin?: string; - ownerName?: string; - groupName?: string; -} - -export interface RecallSearchResult { - hits: SearchHit[]; -} - -export interface RecallEngineLike { - search(params: { - query: string; - maxResults: number; - minScore: number; - ownerFilter?: string[]; - }): Promise; -} - -export interface PendingInjection { - sessionKey: string; - memoryBlock: string; - isSynthetic: boolean; -} - -export interface ContextEngineLogger { - info(msg: string): void; - warn(msg: string): void; - debug(msg: string): void; -} - -// --------------------------------------------------------------------------- -// Message helpers -// --------------------------------------------------------------------------- - -export function getTextFromMessage(msg: AgentMessage): string { - if (typeof msg.content === "string") return msg.content; - if (Array.isArray(msg.content)) { - return msg.content - .filter((b) => b.type === "text" && typeof b.text === "string") - .map((b) => b.text!) - .join(""); - } - return ""; -} - -export function appendMemoryToMessage(msg: AgentMessage, memoryBlock: string): void { - if (typeof msg.content === "string") { - msg.content = msg.content + memoryBlock; - return; - } - if (Array.isArray(msg.content)) { - const lastText = [...msg.content].reverse().find((b) => b.type === "text"); - if (lastText && typeof lastText.text === "string") { - lastText.text += memoryBlock; - } else { - msg.content.push({ type: "text", text: memoryBlock }); - } - return; - } - msg.content = memoryBlock; -} - -const MEMORY_TAG_RE = /\n?[\s\S]*?<\/relevant-memories>/g; - -export function removeExistingMemoryBlock(msg: AgentMessage): void { - if (typeof msg.content === "string") { - msg.content = msg.content.replace(MEMORY_TAG_RE, ""); - return; - } - if (Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "text" && typeof block.text === "string") { - block.text = block.text.replace(MEMORY_TAG_RE, ""); - } - } - } -} - -export function messageHasMemoryBlock(msg: AgentMessage): boolean { - return getTextFromMessage(msg).includes(""); -} - -// --------------------------------------------------------------------------- -// Memory block formatting -// --------------------------------------------------------------------------- - -export function formatMemoryBlock(hits: SearchHit[]): string { - const lines = hits - .map( - (h, i) => - `${i + 1}. [${h.source.role}] ${(h.original_excerpt ?? h.summary).slice(0, 200)}`, - ) - .join("\n"); - return ( - `\n\n` + - `[Memory context relevant to the next user message — injected by user's memory system, not part of assistant's original reply]\n\n` + - `${lines}\n` + - `` - ); -} - -// --------------------------------------------------------------------------- -// Deduplication (shared with main plugin) -// --------------------------------------------------------------------------- - -export function deduplicateHits(hits: T[]): T[] { - const kept: T[] = []; - for (const hit of hits) { - const dominated = kept.some((k) => { - const a = k.summary.toLowerCase(); - const b = hit.summary.toLowerCase(); - if (a === b) return true; - const wordsA = new Set(a.split(/\s+/).filter((w) => w.length > 1)); - const wordsB = new Set(b.split(/\s+/).filter((w) => w.length > 1)); - if (wordsA.size === 0 || wordsB.size === 0) return false; - let overlap = 0; - for (const w of wordsB) { - if (wordsA.has(w)) overlap++; - } - return overlap / Math.min(wordsA.size, wordsB.size) > 0.7; - }); - if (!dominated) kept.push(hit); - } - return kept; -} - -// --------------------------------------------------------------------------- -// Session manager helpers (for maintain() persistence) -// --------------------------------------------------------------------------- - -interface SessionBranchEntry { - id: string; - type: string; - parentId?: string | null; - message?: AgentMessage; - summary?: string; - firstKeptEntryId?: string; - tokensBefore?: number; - details?: unknown; - fromHook?: unknown; - thinkingLevel?: string; - provider?: string; - modelId?: string; - customType?: string; - data?: unknown; - content?: unknown; - display?: unknown; - name?: string; - targetId?: string; - label?: string; -} - -interface SessionManagerLike { - getBranch(): SessionBranchEntry[]; - branch(parentId: string): void; - resetLeaf(): void; - appendMessage(msg: unknown): string; - appendCompaction( - summary: string, - firstKeptEntryId: string, - tokensBefore: number, - details?: unknown, - fromHook?: unknown, - ): string; - appendThinkingLevelChange(level: string): string; - appendModelChange(provider: string, modelId: string): string; - appendCustomEntry(customType: string, data: unknown): string; - appendCustomMessageEntry( - customType: string, - content: unknown, - display: unknown, - details?: unknown, - ): string; - appendSessionInfo(name: string): string; - branchWithSummary( - parentId: string | null, - summary: string, - details?: unknown, - fromHook?: unknown, - ): string; - appendLabelChange(targetId: string, label: string): string; -} - -/** - * Re-append a branch entry preserving its type. Mirrors the - * `appendBranchEntry` pattern from OpenClaw's transcript-rewrite module. - */ -function reappendEntry(sm: SessionManagerLike, entry: SessionBranchEntry): string { - switch (entry.type) { - case "message": - return sm.appendMessage(entry.message); - case "compaction": - return sm.appendCompaction( - entry.summary ?? "", - entry.firstKeptEntryId ?? "", - entry.tokensBefore ?? 0, - entry.details, - entry.fromHook, - ); - case "thinking_level_change": - return sm.appendThinkingLevelChange(entry.thinkingLevel ?? ""); - case "model_change": - return sm.appendModelChange(entry.provider ?? "", entry.modelId ?? ""); - case "custom": - return sm.appendCustomEntry(entry.customType ?? "", entry.data); - case "custom_message": - return sm.appendCustomMessageEntry( - entry.customType ?? "", - entry.content, - entry.display, - entry.details, - ); - case "session_info": - return sm.appendSessionInfo(entry.name ?? ""); - case "branch_summary": - return sm.branchWithSummary( - entry.parentId ?? null, - entry.summary ?? "", - entry.details, - entry.fromHook, - ); - default: - if (entry.targetId !== undefined && entry.label !== undefined) { - return sm.appendLabelChange(entry.targetId, entry.label); - } - return sm.appendMessage(entry.message); - } -} - -/** - * Insert a synthetic assistant message at the start of the session branch - * (before any existing entries). Uses the branch-and-reappend pattern. - */ -export function insertSyntheticAssistantEntry( - sm: SessionManagerLike, - memoryBlock: string, -): boolean { - const branch = sm.getBranch(); - if (branch.length === 0) return false; - - const firstEntry = branch[0]; - if (firstEntry.parentId) { - sm.branch(firstEntry.parentId); - } else { - sm.resetLeaf(); - } - - sm.appendMessage({ - role: "assistant", - content: [{ type: "text", text: memoryBlock }], - timestamp: Date.now(), - stopReason: "end_turn", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, - }); - - for (const entry of branch) { - reappendEntry(sm, entry); - } - return true; -} - -/** - * Find the target assistant entry for memory injection in the session branch. - * Returns the last assistant entry that appears before the last user entry. - */ -export function findTargetAssistantEntry( - branch: SessionBranchEntry[], -): SessionBranchEntry | null { - let lastUserIdx = -1; - for (let i = branch.length - 1; i >= 0; i--) { - if (branch[i].type === "message" && branch[i].message?.role === "user") { - lastUserIdx = i; - break; - } - } - if (lastUserIdx < 0) return null; - - for (let i = lastUserIdx - 1; i >= 0; i--) { - if (branch[i].type === "message" && branch[i].message?.role === "assistant") { - return branch[i]; - } - } - return null; -} diff --git a/apps/memos-local-openclaw/src/hub/server.ts b/apps/memos-local-openclaw/src/hub/server.ts index ec74defa4..d8b697d79 100644 --- a/apps/memos-local-openclaw/src/hub/server.ts +++ b/apps/memos-local-openclaw/src/hub/server.ts @@ -658,6 +658,7 @@ export class HubServer { id: memoryId, sourceChunkId, sourceUserId: auth.userId, + sourceAgent: String(m.sourceAgent || ""), role: String(m.role || "assistant"), content: String(m.content || ""), summary: String(m.summary || ""), @@ -713,9 +714,14 @@ export class HubServer { // Track which IDs are memories vs chunks const memoryIdSet = new Set(memFtsHits.map(({ hit }) => hit.id)); + const ftsHitIdSet = new Set(); + for (const { hit } of ftsHits) ftsHitIdSet.add(hit.id); + for (const { hit } of memFtsHits) ftsHitIdSet.add(hit.id); // Two-stage retrieval: FTS candidates first, then embed + cosine rerank let mergedIds: string[]; + /** Vector RRF channel: require min cosine similarity unless id is already an FTS hit. */ + const MIN_VECTOR_SIM = 0.45; if (this.opts.embedder) { try { const [queryVec] = await this.opts.embedder.embed([query]); @@ -738,8 +744,9 @@ export class HubServer { memoryIdSet.add(e.memoryId); } - scored.sort((a, b) => b.score - a.score); - const topScored = scored.slice(0, maxResults * 2); + const vecCandidates = scored.filter((s) => s.score >= MIN_VECTOR_SIM || ftsHitIdSet.has(s.id)); + vecCandidates.sort((a, b) => b.score - a.score); + const topScored = vecCandidates.slice(0, maxResults * 2); const K = 60; const rrfScores = new Map(); @@ -778,8 +785,8 @@ export class HubServer { this.remoteHitMap.set(remoteHitId, { chunkId: id, type: "memory", expiresAt: Date.now() + 10 * 60 * 1000, requesterUserId: auth.userId }); return { remoteHitId, summary: mhit.summary, excerpt: mhit.content.slice(0, 240), hubRank: rank + 1, - taskTitle: null, ownerName: mhit.owner_name || "unknown", groupName: mhit.group_name, - visibility: mhit.visibility, source: { ts: mhit.created_at, role: mhit.role }, + taskTitle: null, ownerName: mhit.owner_name || "unknown", sourceAgent: (mhit as any).source_agent || "", + groupName: mhit.group_name, visibility: mhit.visibility, source: { ts: mhit.created_at, role: mhit.role }, }; } let hit = ftsMap.get(id); @@ -792,8 +799,8 @@ export class HubServer { this.remoteHitMap.set(remoteHitId, { chunkId: id, type: "chunk", expiresAt: Date.now() + 10 * 60 * 1000, requesterUserId: auth.userId }); return { remoteHitId, summary: hit!.summary, excerpt: hit!.content.slice(0, 240), hubRank: rank + 1, - taskTitle: hit!.task_title, ownerName: hit!.owner_name || "unknown", groupName: hit!.group_name, - visibility: hit!.visibility, source: { ts: hit!.created_at, role: hit!.role }, + taskTitle: hit!.task_title, ownerName: hit!.owner_name || "unknown", sourceAgent: "", + groupName: hit!.group_name, visibility: hit!.visibility, source: { ts: hit!.created_at, role: hit!.role }, }; }).filter(Boolean); return this.json(res, 200, { hits, meta: { totalCandidates: hits.length, searchedGroups: [], includedPublic: true } }); diff --git a/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts b/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts index 3e11d2778..e9845d334 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts @@ -148,19 +148,22 @@ SAME — the new message: - Reports a result, error, or feedback about the current task - Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME) - Is a short acknowledgment (ok, thanks, 好的) in response to the current flow +- Contains pronouns or references (那, 这, 它, 其中, 哪些, those, which, what about, etc.) pointing to items from the current conversation +- Asks about a sub-topic, tool, detail, dimension, or aspect of the current discussion topic NEW — the new message: -- Introduces a subject from a DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel) -- Has NO logical connection to what was being discussed +- Introduces a subject from a COMPLETELY DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel) +- Has NO logical connection to what was being discussed — no shared entities, events, or themes - Starts a request about a different project, system, or life area - Begins with a new greeting/reset followed by a different topic Key principles: -- If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW +- Default to SAME unless the topic domain CLEARLY changed. When in doubt, choose SAME. +- CRITICAL: Short messages (under ~30 characters) that use pronouns or ask "what about X" / "哪些" / "那XX呢" are almost always follow-ups referring to the current topic. Only mark them NEW if they explicitly name a completely unrelated domain. - Different aspects of the SAME project/system are SAME (e.g., Nginx SSL → Nginx gzip = SAME) -- Different unrelated technologies discussed independently are NEW (e.g., Redis config → cooking recipe = NEW) -- When unsure, lean toward SAME for closely related topics, but do NOT hesitate to mark NEW for obvious domain shifts -- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "MySQL配置" → "K8s部署" in same infra project = SAME; "部署服务器" → "年会安排" = NEW +- Asking about tools, systems, or methods for the current topic is SAME (e.g., "港股调研" → "那处理系统有哪些" = SAME; "数据分析" → "用什么工具" = SAME) +- Different unrelated domains discussed independently are NEW (e.g., Redis config → cooking recipe = NEW) +- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "港股调研" → "那处理系统有哪些" = SAME; "部署服务器" → "年会安排" = NEW Output exactly one word: NEW or SAME`; diff --git a/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts b/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts index d2f582aba..1fcd0b359 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts @@ -150,19 +150,22 @@ SAME — the new message: - Reports a result, error, or feedback about the current task - Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME) - Is a short acknowledgment (ok, thanks, 好的) in response to the current flow +- Contains pronouns or references (那, 这, 它, 其中, 哪些, those, which, what about, etc.) pointing to items from the current conversation +- Asks about a sub-topic, tool, detail, dimension, or aspect of the current discussion topic NEW — the new message: -- Introduces a subject from a DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel) -- Has NO logical connection to what was being discussed +- Introduces a subject from a COMPLETELY DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel) +- Has NO logical connection to what was being discussed — no shared entities, events, or themes - Starts a request about a different project, system, or life area - Begins with a new greeting/reset followed by a different topic Key principles: -- If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW +- Default to SAME unless the topic domain CLEARLY changed. When in doubt, choose SAME. +- CRITICAL: Short messages (under ~30 characters) that use pronouns or ask "what about X" / "哪些" / "那XX呢" are almost always follow-ups referring to the current topic. Only mark them NEW if they explicitly name a completely unrelated domain. - Different aspects of the SAME project/system are SAME (e.g., Nginx SSL → Nginx gzip = SAME) -- Different unrelated technologies discussed independently are NEW (e.g., Redis config → cooking recipe = NEW) -- When unsure, lean toward SAME for closely related topics, but do NOT hesitate to mark NEW for obvious domain shifts -- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "MySQL配置" → "K8s部署" in same infra project = SAME; "部署服务器" → "年会安排" = NEW +- Asking about tools, systems, or methods for the current topic is SAME (e.g., "港股调研" → "那处理系统有哪些" = SAME; "数据分析" → "用什么工具" = SAME) +- Different unrelated domains discussed independently are NEW (e.g., Redis config → cooking recipe = NEW) +- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "港股调研" → "那处理系统有哪些" = SAME; "部署服务器" → "年会安排" = NEW Output exactly one word: NEW or SAME`; diff --git a/apps/memos-local-openclaw/src/ingest/providers/gemini.ts b/apps/memos-local-openclaw/src/ingest/providers/gemini.ts index b6659b2d1..3fafe570d 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/gemini.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/gemini.ts @@ -148,19 +148,22 @@ SAME — the new message: - Reports a result, error, or feedback about the current task - Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME) - Is a short acknowledgment (ok, thanks, 好的) in response to the current flow +- Contains pronouns or references (那, 这, 它, 其中, 哪些, those, which, what about, etc.) pointing to items from the current conversation +- Asks about a sub-topic, tool, detail, dimension, or aspect of the current discussion topic NEW — the new message: -- Introduces a subject from a DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel) -- Has NO logical connection to what was being discussed +- Introduces a subject from a COMPLETELY DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel) +- Has NO logical connection to what was being discussed — no shared entities, events, or themes - Starts a request about a different project, system, or life area - Begins with a new greeting/reset followed by a different topic Key principles: -- If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW +- Default to SAME unless the topic domain CLEARLY changed. When in doubt, choose SAME. +- CRITICAL: Short messages (under ~30 characters) that use pronouns or ask "what about X" / "哪些" / "那XX呢" are almost always follow-ups referring to the current topic. Only mark them NEW if they explicitly name a completely unrelated domain. - Different aspects of the SAME project/system are SAME (e.g., Nginx SSL → Nginx gzip = SAME) -- Different unrelated technologies discussed independently are NEW (e.g., Redis config → cooking recipe = NEW) -- When unsure, lean toward SAME for closely related topics, but do NOT hesitate to mark NEW for obvious domain shifts -- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "MySQL配置" → "K8s部署" in same infra project = SAME; "部署服务器" → "年会安排" = NEW +- Asking about tools, systems, or methods for the current topic is SAME (e.g., "港股调研" → "那处理系统有哪些" = SAME; "数据分析" → "用什么工具" = SAME) +- Different unrelated domains discussed independently are NEW (e.g., Redis config → cooking recipe = NEW) +- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "港股调研" → "那处理系统有哪些" = SAME; "部署服务器" → "年会安排" = NEW Output exactly one word: NEW or SAME`; diff --git a/apps/memos-local-openclaw/src/ingest/providers/index.ts b/apps/memos-local-openclaw/src/ingest/providers/index.ts index 99db7b63e..b08818520 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/index.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/index.ts @@ -1,9 +1,9 @@ import * as fs from "fs"; import * as path from "path"; -import type { SummarizerConfig, SummaryProvider, Logger } from "../../types"; -import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, filterRelevantOpenAI, judgeDedupOpenAI } from "./openai"; -import type { FilterResult, DedupResult } from "./openai"; -export type { FilterResult, DedupResult } from "./openai"; +import type { SummarizerConfig, SummaryProvider, Logger, OpenClawAPI } from "../../types"; +import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, classifyTopicOpenAI, arbitrateTopicSplitOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult, parseTopicClassifyResult } from "./openai"; +import type { FilterResult, DedupResult, TopicClassifyResult } from "./openai"; +export type { FilterResult, DedupResult, TopicClassifyResult } from "./openai"; import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic } from "./anthropic"; import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini } from "./gemini"; import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock } from "./bedrock"; @@ -287,25 +287,30 @@ export class Summarizer { } async judgeNewTopic(currentContext: string, newMessage: string): Promise { - const chain: SummarizerConfig[] = []; - if (this.strongCfg) chain.push(this.strongCfg); - if (this.fallbackCfg) chain.push(this.fallbackCfg); - if (chain.length === 0 && this.cfg) chain.push(this.cfg); - if (chain.length === 0) return null; + const result = await this.tryChain("judgeNewTopic", (cfg) => + cfg.provider === "openclaw" + ? this.judgeNewTopicOpenClaw(currentContext, newMessage) + : callTopicJudge(cfg, currentContext, newMessage, this.log), + ); + return result ?? null; + } - for (let i = 0; i < chain.length; i++) { - const modelInfo = `${chain[i].provider}/${chain[i].model ?? "?"}`; - try { - const result = await callTopicJudge(chain[i], currentContext, newMessage, this.log); - modelHealth.recordSuccess("judgeNewTopic", modelInfo); - return result; - } catch (err) { - const level = i < chain.length - 1 ? "warn" : "error"; - this.log[level](`judgeNewTopic failed (${modelInfo}), ${i < chain.length - 1 ? "trying next" : "no more fallbacks"}: ${err}`); - modelHealth.recordError("judgeNewTopic", modelInfo, String(err)); - } - } - return null; + async classifyTopic(taskState: string, newMessage: string): Promise { + const result = await this.tryChain("classifyTopic", (cfg) => + cfg.provider === "openclaw" + ? this.classifyTopicOpenClaw(taskState, newMessage) + : callTopicClassifier(cfg, taskState, newMessage, this.log), + ); + return result ?? null; + } + + async arbitrateTopicSplit(taskState: string, newMessage: string): Promise { + const result = await this.tryChain("arbitrateTopicSplit", (cfg) => + cfg.provider === "openclaw" + ? this.arbitrateTopicSplitOpenClaw(taskState, newMessage) + : callTopicArbitration(cfg, taskState, newMessage, this.log), + ); + return result ?? null; } async filterRelevant( @@ -346,8 +351,19 @@ export class Summarizer { static readonly OPENCLAW_TOPIC_JUDGE_PROMPT = `You are a conversation topic change detector. Given a CURRENT CONVERSATION SUMMARY and a NEW USER MESSAGE, decide: has the user started a COMPLETELY NEW topic that is unrelated to the current conversation? +Default to SAME unless the domain clearly changed. If the new message shares the same person, event, entity, or theme with the current conversation, answer SAME. +CRITICAL: Short messages (under ~30 characters) that use pronouns (那/这/它/哪些) or ask about tools/details/dimensions of the current topic are almost always follow-ups — answer SAME unless they explicitly name a completely unrelated domain. Reply with a single word: "NEW" if topic changed, "SAME" if it continues.`; + static readonly OPENCLAW_TOPIC_CLASSIFIER_PROMPT = `Classify if NEW MESSAGE continues current task or starts an unrelated one. +Output ONLY JSON: {"d":"S"|"N","c":0.0-1.0} +d=S(same) or N(new). c=confidence. Default S. Only N if completely unrelated domain. +Sub-questions, tools, methods, details of current topic = S.`; + + static readonly OPENCLAW_TOPIC_ARBITRATION_PROMPT = `A classifier flagged this message as possibly new topic (low confidence). Is it truly UNRELATED, or a sub-question/follow-up? +Tools/methods/details of current task = SAME. Shared entity/theme = SAME. Entirely different domain = NEW. +Reply one word: NEW or SAME`; + static readonly OPENCLAW_FILTER_RELEVANT_PROMPT = `You are a memory relevance judge. Given a QUERY and CANDIDATE memories, decide: does each candidate help answer the query? RULES: @@ -433,6 +449,45 @@ Reply with JSON: {"action":"MERGE","mergeTarget":2,"reason":"..."} or {"action": return answer.startsWith("NEW"); } + private async classifyTopicOpenClaw(taskState: string, newMessage: string): Promise { + this.requireOpenClawAPI(); + const prompt = [ + Summarizer.OPENCLAW_TOPIC_CLASSIFIER_PROMPT, + ``, + `TASK:\n${taskState}`, + `\nMSG:\n${newMessage}`, + ].join("\n"); + + const response = await this.openclawAPI!.complete({ + prompt, + maxTokens: 60, + temperature: 0, + model: this.cfg?.model, + }); + + return parseTopicClassifyResult(response.text.trim(), this.log); + } + + private async arbitrateTopicSplitOpenClaw(taskState: string, newMessage: string): Promise { + this.requireOpenClawAPI(); + const prompt = [ + Summarizer.OPENCLAW_TOPIC_ARBITRATION_PROMPT, + ``, + `TASK:\n${taskState}`, + `\nMSG:\n${newMessage}`, + ].join("\n"); + + const response = await this.openclawAPI!.complete({ + prompt, + maxTokens: 10, + temperature: 0, + model: this.cfg?.model, + }); + + const answer = response.text.trim().toUpperCase(); + return answer.startsWith("NEW") ? "NEW" : "SAME"; + } + private async filterRelevantOpenClaw( query: string, candidates: Array<{ index: number; role: string; content: string; time?: string }>, @@ -643,6 +698,52 @@ function callJudgeDedup(cfg: SummarizerConfig, newSummary: string, candidates: A } } +function callTopicClassifier(cfg: SummarizerConfig, taskState: string, newMessage: string, log: Logger): Promise { + switch (cfg.provider) { + case "openai": + case "openai_compatible": + case "azure_openai": + case "zhipu": + case "siliconflow": + case "deepseek": + case "moonshot": + case "bailian": + case "cohere": + case "mistral": + case "voyage": + return classifyTopicOpenAI(taskState, newMessage, cfg, log); + case "anthropic": + case "gemini": + case "bedrock": + return classifyTopicOpenAI(taskState, newMessage, cfg, log); + default: + throw new Error(`Unknown summarizer provider: ${cfg.provider}`); + } +} + +function callTopicArbitration(cfg: SummarizerConfig, taskState: string, newMessage: string, log: Logger): Promise { + switch (cfg.provider) { + case "openai": + case "openai_compatible": + case "azure_openai": + case "zhipu": + case "siliconflow": + case "deepseek": + case "moonshot": + case "bailian": + case "cohere": + case "mistral": + case "voyage": + return arbitrateTopicSplitOpenAI(taskState, newMessage, cfg, log); + case "anthropic": + case "gemini": + case "bedrock": + return arbitrateTopicSplitOpenAI(taskState, newMessage, cfg, log); + default: + throw new Error(`Unknown summarizer provider: ${cfg.provider}`); + } +} + // ─── Fallbacks ─── function ruleFallback(text: string): string { diff --git a/apps/memos-local-openclaw/src/ingest/providers/openai.ts b/apps/memos-local-openclaw/src/ingest/providers/openai.ts index 23d8a9fe6..825e2131d 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/openai.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/openai.ts @@ -188,19 +188,26 @@ SAME — the new message: - Reports a result, error, or feedback about the current task - Discusses different tools or approaches for the SAME goal (e.g., learning English via BBC → via ChatGPT = SAME) - Is a short acknowledgment (ok, thanks, 好的) in response to the current flow +- Is a follow-up, update, or different angle on the same news event, person, or story +- Shares the same core entity (person, company, event) even if the specific detail or angle differs +- Contains pronouns or references (那, 这, 它, 其中, 哪些, those, which, what about, etc.) pointing to items from the current conversation +- Asks about a sub-topic, tool, detail, dimension, or aspect of the current discussion topic NEW — the new message: -- Introduces a subject from a DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel) -- Has NO logical connection to what was being discussed +- Introduces a subject from a COMPLETELY DIFFERENT domain than the current task (e.g., tech → cooking, work → personal life, database → travel) +- Has NO logical connection to what was being discussed — no shared entities, events, or themes - Starts a request about a different project, system, or life area - Begins with a new greeting/reset followed by a different topic Key principles: -- If the topic domain clearly changed (e.g., server config → recipe, code review → vacation plan), choose NEW +- Default to SAME unless the topic domain CLEARLY changed. When in doubt, choose SAME. +- CRITICAL: Short messages (under ~30 characters) that use pronouns or ask "what about X" / "哪些" / "那XX呢" are almost always follow-ups referring to the current topic. Only mark them NEW if they explicitly name a completely unrelated domain. +- If the new message mentions the same person, event, product, or entity as the current task, it is SAME regardless of the angle - Different aspects of the SAME project/system are SAME (e.g., Nginx SSL → Nginx gzip = SAME) -- Different unrelated technologies discussed independently are NEW (e.g., Redis config → cooking recipe = NEW) -- When unsure, lean toward SAME for closely related topics, but do NOT hesitate to mark NEW for obvious domain shifts -- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "MySQL配置" → "K8s部署" in same infra project = SAME; "部署服务器" → "年会安排" = NEW +- Asking about tools, systems, or methods for the current topic is SAME (e.g., "港股调研" → "那处理系统有哪些" = SAME; "数据分析" → "用什么工具" = SAME) +- Follow-up news about the same event is SAME (e.g., "博士失联" → "博士遗体被找到" = SAME; "产品发布" → "产品销量" = SAME) +- Different unrelated domains discussed independently are NEW (e.g., Redis config → cooking recipe = NEW) +- Examples: "配置Nginx" → "加gzip压缩" = SAME; "配置Nginx" → "做红烧肉" = NEW; "港股调研" → "那处理系统有哪些" = SAME; "部署服务器" → "年会安排" = NEW Output exactly one word: NEW or SAME`; @@ -246,6 +253,134 @@ export async function judgeNewTopicOpenAI( return answer.startsWith("NEW"); } +// ─── Structured Topic Classifier ─── + +export interface TopicClassifyResult { + decision: "NEW" | "SAME"; + confidence: number; + boundaryType: string; + reason: string; // may be empty for compact responses +} + +const TOPIC_CLASSIFIER_PROMPT = `Classify if NEW MESSAGE continues current task or starts an unrelated one. +Output ONLY JSON: {"d":"S"|"N","c":0.0-1.0} +d=S(same) or N(new). c=confidence. Default S. Only N if completely unrelated domain. +Sub-questions, tools, methods, details of current topic = S.`; + +export async function classifyTopicOpenAI( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const endpoint = normalizeChatEndpoint(cfg.endpoint ?? "https://api.openai.com/v1/chat/completions"); + const model = cfg.model ?? "gpt-4o-mini"; + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${cfg.apiKey}`, + ...cfg.headers, + }; + + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + + const resp = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify(buildRequestBody(cfg, { + model, + temperature: 0, + max_tokens: 60, + messages: [ + { role: "system", content: TOPIC_CLASSIFIER_PROMPT }, + { role: "user", content: userContent }, + ], + })), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`OpenAI topic-classifier failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { choices: Array<{ message: { content: string } }> }; + const raw = json.choices[0]?.message?.content?.trim() ?? ""; + log.debug(`Topic classifier raw: "${raw}"`); + + return parseTopicClassifyResult(raw, log); +} + +const TOPIC_ARBITRATION_PROMPT = `A classifier flagged this message as possibly new topic (low confidence). Is it truly UNRELATED, or a sub-question/follow-up? +Tools/methods/details of current task = SAME. Shared entity/theme = SAME. Entirely different domain = NEW. +Reply one word: NEW or SAME`; + +export async function arbitrateTopicSplitOpenAI( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const endpoint = normalizeChatEndpoint(cfg.endpoint ?? "https://api.openai.com/v1/chat/completions"); + const model = cfg.model ?? "gpt-4o-mini"; + const headers: Record = { + "Content-Type": "application/json", + Authorization: `Bearer ${cfg.apiKey}`, + ...cfg.headers, + }; + + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + + const resp = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify(buildRequestBody(cfg, { + model, + temperature: 0, + max_tokens: 10, + messages: [ + { role: "system", content: TOPIC_ARBITRATION_PROMPT }, + { role: "user", content: userContent }, + ], + })), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`OpenAI topic-arbitration failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { choices: Array<{ message: { content: string } }> }; + const answer = json.choices[0]?.message?.content?.trim().toUpperCase() ?? ""; + log.debug(`Topic arbitration result: "${answer}"`); + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + +export function parseTopicClassifyResult(raw: string, log: Logger): TopicClassifyResult { + try { + const jsonMatch = raw.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const p = JSON.parse(jsonMatch[0]); + const decision: "NEW" | "SAME" = + (p.d === "N" || p.decision === "NEW") ? "NEW" : "SAME"; + const confidence: number = + typeof p.c === "number" ? p.c : typeof p.confidence === "number" ? p.confidence : 0.5; + return { + decision, + confidence, + boundaryType: p.boundaryType || "", + reason: p.reason || "", + }; + } + } catch (err) { + log.debug(`Failed to parse topic classify JSON: ${err}`); + } + const upper = raw.toUpperCase(); + if (upper.startsWith("NEW") || upper.startsWith("N")) + return { decision: "NEW", confidence: 0.5, boundaryType: "", reason: "parse fallback" }; + return { decision: "SAME", confidence: 0.5, boundaryType: "", reason: "parse fallback" }; +} + const FILTER_RELEVANT_PROMPT = `You are a memory relevance judge. Given a QUERY and CANDIDATE memories, decide: does each candidate's content contain information that would HELP ANSWER the query? diff --git a/apps/memos-local-openclaw/src/ingest/task-processor.ts b/apps/memos-local-openclaw/src/ingest/task-processor.ts index 67cca0fd4..29f0d796f 100644 --- a/apps/memos-local-openclaw/src/ingest/task-processor.ts +++ b/apps/memos-local-openclaw/src/ingest/task-processor.ts @@ -51,6 +51,9 @@ export class TaskProcessor { * Determines if a new task boundary was crossed and handles transition. */ async onChunksIngested(sessionKey: string, latestTimestamp: number, owner?: string): Promise { + if (sessionKey.startsWith("temp:") || sessionKey.startsWith("internal:") || sessionKey.startsWith("system:")) { + return; + } const resolvedOwner = owner ?? "agent:main"; this.ctx.log.debug(`TaskProcessor.onChunksIngested called session=${sessionKey} ts=${latestTimestamp} owner=${resolvedOwner} processing=${this.processing}`); this.pendingEvents.push({ sessionKey, latestTimestamp, owner: resolvedOwner }); @@ -79,13 +82,19 @@ export class TaskProcessor { } } + private static extractAgentPrefix(sessionKey: string): string { + const parts = sessionKey.split(":"); + return parts.length >= 3 ? parts.slice(0, 3).join(":") : sessionKey; + } + private async detectAndProcess(sessionKey: string, latestTimestamp: number, owner: string): Promise { this.ctx.log.debug(`TaskProcessor.detectAndProcess session=${sessionKey} owner=${owner}`); + const currentAgentPrefix = TaskProcessor.extractAgentPrefix(sessionKey); const allActive = this.store.getAllActiveTasks(owner); for (const t of allActive) { - if (t.sessionKey !== sessionKey) { - this.ctx.log.info(`Session changed: finalizing task=${t.id} from session=${t.sessionKey} (owner=${owner})`); + if (t.sessionKey !== sessionKey && TaskProcessor.extractAgentPrefix(t.sessionKey) === currentAgentPrefix) { + this.ctx.log.info(`Session changed within agent: finalizing task=${t.id} from session=${t.sessionKey} (owner=${owner})`); await this.finalizeTask(t); } } @@ -179,26 +188,36 @@ export class TaskProcessor { continue; } - // LLM topic judgment — check this single user message against full task context - const context = this.buildContextSummary(currentTaskChunks); + // Structured topic classification + const taskState = this.buildTopicJudgeState(currentTaskChunks, userChunk); const newMsg = userChunk.content.slice(0, 500); - this.ctx.log.info(`Topic judge: "${newMsg.slice(0, 60)}" vs ${existingUserCount} user turns`); - const isNew = await this.summarizer.judgeNewTopic(context, newMsg); - this.ctx.log.info(`Topic judge result: ${isNew === null ? "null(fallback)" : isNew ? "NEW" : "SAME"}`); + this.ctx.log.info(`Topic classify: "${newMsg.slice(0, 60)}" vs ${existingUserCount} user turns`); + const result = await this.summarizer.classifyTopic(taskState, newMsg); + this.ctx.log.info(`Topic classify: decision=${result?.decision ?? "null"} confidence=${result?.confidence ?? "?"} type=${result?.boundaryType ?? "?"} reason=${result?.reason ?? ""}`); - if (isNew === null) { + if (!result || result.decision === "SAME") { this.assignChunksToTask(turn, currentTask.id); currentTaskChunks = currentTaskChunks.concat(turn); continue; } - if (isNew) { - this.ctx.log.info(`Task boundary at turn ${i}: LLM judged new topic. Msg: "${newMsg.slice(0, 80)}..."`); - await this.finalizeTask(currentTask); - currentTask = await this.createNewTaskReturn(sessionKey, userChunk.createdAt, owner); - currentTaskChunks = []; + // Low-confidence NEW: second-pass arbitration + if (result.confidence < 0.65) { + this.ctx.log.info(`Low confidence NEW (${result.confidence}), running second-pass arbitration...`); + const secondResult = await this.summarizer.arbitrateTopicSplit(taskState, newMsg); + this.ctx.log.info(`Second-pass result: ${secondResult ?? "null(fallback->SAME)"}`); + if (!secondResult || secondResult !== "NEW") { + this.assignChunksToTask(turn, currentTask.id); + currentTaskChunks = currentTaskChunks.concat(turn); + continue; + } } + this.ctx.log.info(`Task boundary at turn ${i}: classifier judged NEW (confidence=${result.confidence}). Msg: "${newMsg.slice(0, 80)}..."`); + await this.finalizeTask(currentTask); + currentTask = await this.createNewTaskReturn(sessionKey, userChunk.createdAt, owner); + currentTaskChunks = []; + this.assignChunksToTask(turn, currentTask.id); currentTaskChunks = currentTaskChunks.concat(turn); } @@ -226,38 +245,39 @@ export class TaskProcessor { } /** - * Build context from existing task chunks for the LLM topic judge. - * Includes both the task's opening topic and recent exchanges, - * so the LLM understands both what the task was originally about - * and where the conversation currently is. - * - * For user messages, include full content (up to 500 chars) since - * they carry the topic signal. For assistant messages, use summary - * or truncated content since they mostly elaborate. + * Build compact task state for the LLM topic classifier. + * Includes: topic (first user msg), last 3 turn summaries, + * and optional assistant snippet for short/ambiguous messages. */ - private buildContextSummary(chunks: Chunk[]): string { - const conversational = chunks.filter((c) => c.role === "user" || c.role === "assistant"); - if (conversational.length === 0) return ""; - - const formatChunk = (c: Chunk) => { - const label = c.role === "user" ? "User" : "Assistant"; - const maxLen = c.role === "user" ? 500 : 200; - const text = c.summary || c.content.slice(0, maxLen); - return `[${label}]: ${text}`; - }; + private buildTopicJudgeState(chunks: Chunk[], newUserChunk: Chunk): string { + const conv = chunks.filter((c) => c.role === "user" || c.role === "assistant"); + if (conv.length === 0) return ""; + + const firstUser = conv.find((c) => c.role === "user"); + const topic = firstUser?.summary || firstUser?.content.slice(0, 80) || ""; + + const turns: Array<{ u: string; a: string }> = []; + for (let j = 0; j < conv.length; j++) { + if (conv[j].role === "user") { + const u = conv[j].summary || conv[j].content.slice(0, 60); + const nextA = conv[j + 1]?.role === "assistant" ? conv[j + 1] : null; + const a = nextA ? (nextA.summary || nextA.content.slice(0, 60)) : ""; + turns.push({ u, a }); + } + } + + const recent = turns.slice(-3); + const turnLines = recent.map((t, i) => `${i + 1}. U:${t.u} A:${t.a}`); - if (conversational.length <= 10) { - return conversational.map(formatChunk).join("\n"); + let snippet = ""; + if (newUserChunk.content.length < 30 || /^[那这它其还哪啥]/.test(newUserChunk.content.trim())) { + const lastA = [...conv].reverse().find((c) => c.role === "assistant"); + if (lastA) snippet = lastA.content.slice(0, 200); } - const opening = conversational.slice(0, 6).map(formatChunk); - const recent = conversational.slice(-4).map(formatChunk); - return [ - "--- Task opening ---", - ...opening, - "--- Recent exchanges ---", - ...recent, - ].join("\n"); + const parts = [`topic:${topic}`, ...turnLines]; + if (snippet) parts.push(`lastA:${snippet}`); + return parts.join("\n"); } private async createNewTaskReturn(sessionKey: string, timestamp: number, owner: string = "agent:main"): Promise { diff --git a/apps/memos-local-openclaw/src/ingest/worker.ts b/apps/memos-local-openclaw/src/ingest/worker.ts index 55d1718b9..d62ab4a2a 100644 --- a/apps/memos-local-openclaw/src/ingest/worker.ts +++ b/apps/memos-local-openclaw/src/ingest/worker.ts @@ -25,8 +25,14 @@ export class IngestWorker { getTaskProcessor(): TaskProcessor { return this.taskProcessor; } + private static isEphemeralSession(sessionKey: string): boolean { + return sessionKey.startsWith("temp:") || sessionKey.startsWith("internal:") || sessionKey.startsWith("system:"); + } + enqueue(messages: ConversationMessage[]): void { - this.queue.push(...messages); + const filtered = messages.filter((m) => !IngestWorker.isEphemeralSession(m.sessionKey)); + if (filtered.length === 0) return; + this.queue.push(...filtered); if (!this.processing) { this.processQueue().catch((err) => { this.ctx.log.error(`Ingest worker error: ${err}`); @@ -150,14 +156,23 @@ export class IngestWorker { let mergeHistory = "[]"; // Fast path: exact content_hash match within same owner (agent dimension) + // Strategy: retire the OLD chunk, keep the NEW one active (latest wins) const chunkOwner = msg.owner ?? "agent:main"; const existingByHash = this.store.findActiveChunkByHash(content, chunkOwner); if (existingByHash) { - this.ctx.log.debug(`Exact-dup (owner=${chunkOwner}): hash match → existing=${existingByHash}`); + this.ctx.log.debug(`Exact-dup (owner=${chunkOwner}): hash match → retiring old=${existingByHash}, keeping new=${chunkId}`); this.store.recordMergeHit(existingByHash, "DUPLICATE", "exact content hash match"); - dedupStatus = "duplicate"; - dedupTarget = existingByHash; + const oldChunk = this.store.getChunk(existingByHash); + this.store.markDedupStatus(existingByHash, "duplicate", chunkId, "exact content hash match"); + this.store.deleteEmbedding(existingByHash); + mergedFromOld = existingByHash; dedupReason = "exact content hash match"; + if (oldChunk) { + const oldHistory = JSON.parse(oldChunk.mergeHistory || "[]"); + oldHistory.push({ action: "duplicate_superseded", at: Date.now(), reason: "exact content hash match", sourceChunkId: existingByHash }); + mergeHistory = JSON.stringify(oldHistory); + mergeCount = (oldChunk.mergeCount || 0) + 1; + } } // Smart dedup: find Top-5 similar chunks, then ask LLM to judge @@ -173,8 +188,9 @@ export class IngestWorker { index: i + 1, summary: chunk?.summary ?? "", chunkId: s.chunkId, + role: chunk?.role, }; - }).filter(c => c.summary); + }).filter(c => c.summary && c.role === msg.role); if (candidates.length > 0) { const dedupResult = await this.summarizer.judgeDedup(summary, candidates); @@ -183,10 +199,18 @@ export class IngestWorker { const targetChunkId = candidates[dedupResult.targetIndex - 1]?.chunkId; if (targetChunkId) { this.store.recordMergeHit(targetChunkId, "DUPLICATE", dedupResult.reason); - dedupStatus = "duplicate"; - dedupTarget = targetChunkId; + const oldChunk = this.store.getChunk(targetChunkId); + this.store.markDedupStatus(targetChunkId, "duplicate", chunkId, dedupResult.reason); + this.store.deleteEmbedding(targetChunkId); + mergedFromOld = targetChunkId; dedupReason = dedupResult.reason; - this.ctx.log.debug(`Smart dedup: DUPLICATE → target=${targetChunkId}, storing with status=duplicate, reason: ${dedupResult.reason}`); + if (oldChunk) { + const oldHistory = JSON.parse(oldChunk.mergeHistory || "[]"); + oldHistory.push({ action: "duplicate_superseded", at: Date.now(), reason: dedupResult.reason, sourceChunkId: targetChunkId }); + mergeHistory = JSON.stringify(oldHistory); + mergeCount = (oldChunk.mergeCount || 0) + 1; + } + this.ctx.log.debug(`Smart dedup: DUPLICATE → retiring old=${targetChunkId}, keeping new=${chunkId} active, reason: ${dedupResult.reason}`); } } @@ -266,9 +290,6 @@ export class IngestWorker { } this.ctx.log.debug(`Stored chunk=${chunkId} kind=${kind} role=${msg.role} dedup=${dedupStatus} len=${content.length} hasVec=${!!embedding && dedupStatus === "active"}`); - if (dedupStatus === "duplicate") { - return { action: "duplicate", summary, targetChunkId: dedupTarget ?? undefined, reason: dedupReason ?? undefined }; - } if (mergedFromOld) { return { action: "merged", chunkId, summary, targetChunkId: mergedFromOld, reason: dedupReason ?? undefined }; } diff --git a/apps/memos-local-openclaw/src/recall/engine.ts b/apps/memos-local-openclaw/src/recall/engine.ts index cca29b8ed..711bde5a0 100644 --- a/apps/memos-local-openclaw/src/recall/engine.ts +++ b/apps/memos-local-openclaw/src/recall/engine.ts @@ -77,7 +77,7 @@ export class RecallEngine { } const shortTerms = [...new Set([...spaceSplit, ...cjkBigrams])]; const patternHits = shortTerms.length > 0 - ? this.store.patternSearch(shortTerms, { limit: candidatePool }) + ? this.store.patternSearch(shortTerms, { limit: candidatePool, ownerFilter }) : []; const patternRanked = patternHits.map((h, i) => ({ id: h.chunkId, @@ -234,6 +234,7 @@ export class RecallEngine { score: Math.round(candidate.score * 1000) / 1000, taskId: chunk.taskId, skillId: chunk.skillId, + owner: chunk.owner, origin: chunk.owner === "public" ? "local-shared" : "local", source: { ts: chunk.createdAt, diff --git a/apps/memos-local-openclaw/src/sharing/types.ts b/apps/memos-local-openclaw/src/sharing/types.ts index aa97a5de1..79f9afe4d 100644 --- a/apps/memos-local-openclaw/src/sharing/types.ts +++ b/apps/memos-local-openclaw/src/sharing/types.ts @@ -38,6 +38,7 @@ export interface HubSearchHit { hubRank: number; taskTitle: string | null; ownerName: string; + sourceAgent: string; groupName: string | null; visibility: SharedVisibility; source: { diff --git a/apps/memos-local-openclaw/src/storage/sqlite.ts b/apps/memos-local-openclaw/src/storage/sqlite.ts index 5bebd07a4..09f9c2bf7 100644 --- a/apps/memos-local-openclaw/src/storage/sqlite.ts +++ b/apps/memos-local-openclaw/src/storage/sqlite.ts @@ -110,9 +110,12 @@ export class SqliteStore { this.migrateOwnerFields(); this.migrateSkillVisibility(); this.migrateSkillEmbeddingsAndFts(); + this.migrateTaskTopicColumn(); + this.migrateTaskEmbeddingsAndFts(); this.migrateFtsToTrigram(); this.migrateHubTables(); this.migrateHubFtsToTrigram(); + this.migrateHubMemorySourceAgent(); this.migrateLocalSharedTasksOwner(); this.migrateHubUserIdentityFields(); this.migrateClientHubConnectionIdentityFields(); @@ -124,6 +127,16 @@ export class SqliteStore { this.db.exec("CREATE INDEX IF NOT EXISTS idx_chunks_dedup_created ON chunks(dedup_status, created_at DESC)"); } + private migrateHubMemorySourceAgent(): void { + try { + const cols = this.db.prepare("PRAGMA table_info(hub_memories)").all() as Array<{ name: string }>; + if (cols.length > 0 && !cols.some((c) => c.name === "source_agent")) { + this.db.exec("ALTER TABLE hub_memories ADD COLUMN source_agent TEXT NOT NULL DEFAULT ''"); + this.log.info("Migrated: added source_agent column to hub_memories"); + } + } catch { /* table may not exist yet */ } + } + private migrateLocalSharedTasksOwner(): void { try { const cols = this.db.prepare("PRAGMA table_info(local_shared_tasks)").all() as Array<{ name: string }>; @@ -290,6 +303,55 @@ export class SqliteStore { } catch { /* best-effort */ } } + private migrateTaskEmbeddingsAndFts(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS task_embeddings ( + task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE, + vector BLOB NOT NULL, + dimensions INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5( + summary, + topic, + content='tasks', + content_rowid='rowid', + tokenize='trigram' + ); + `); + + try { + this.db.exec(` + CREATE TRIGGER IF NOT EXISTS tasks_fts_ai AFTER INSERT ON tasks BEGIN + INSERT INTO tasks_fts(rowid, summary, topic) + VALUES (new.rowid, new.summary, COALESCE(new.topic, '')); + END; + CREATE TRIGGER IF NOT EXISTS tasks_fts_ad AFTER DELETE ON tasks BEGIN + INSERT INTO tasks_fts(tasks_fts, rowid, summary, topic) + VALUES ('delete', old.rowid, old.summary, COALESCE(old.topic, '')); + END; + CREATE TRIGGER IF NOT EXISTS tasks_fts_au AFTER UPDATE ON tasks BEGIN + INSERT INTO tasks_fts(tasks_fts, rowid, summary, topic) + VALUES ('delete', old.rowid, old.summary, COALESCE(old.topic, '')); + INSERT INTO tasks_fts(rowid, summary, topic) + VALUES (new.rowid, new.summary, COALESCE(new.topic, '')); + END; + `); + } catch { + // triggers may already exist + } + + try { + const count = (this.db.prepare("SELECT COUNT(*) as c FROM tasks_fts").get() as { c: number }).c; + const taskCount = (this.db.prepare("SELECT COUNT(*) as c FROM tasks").get() as { c: number }).c; + if (count === 0 && taskCount > 0) { + this.db.exec("INSERT INTO tasks_fts(rowid, summary, topic) SELECT rowid, summary, COALESCE(topic, '') FROM tasks"); + this.log.info(`Migrated: backfilled tasks_fts for ${taskCount} tasks`); + } + } catch { /* best-effort */ } + } + private migrateFtsToTrigram(): void { // Check if chunks_fts still uses the old tokenizer (porter unicode61) try { @@ -507,6 +569,14 @@ export class SqliteStore { } } + private migrateTaskTopicColumn(): void { + const cols = this.db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + if (!cols.some((c) => c.name === "topic")) { + this.db.exec("ALTER TABLE tasks ADD COLUMN topic TEXT DEFAULT NULL"); + this.log.info("Migrated: added topic column to tasks"); + } + } + private migrateTaskSkillMeta(): void { const cols = this.db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; if (!cols.some((c) => c.name === "skill_status")) { @@ -992,6 +1062,7 @@ export class SqliteStore { id TEXT PRIMARY KEY, source_chunk_id TEXT NOT NULL, source_user_id TEXT NOT NULL, + source_agent TEXT NOT NULL DEFAULT '', role TEXT NOT NULL, content TEXT NOT NULL, summary TEXT NOT NULL DEFAULT '', @@ -1207,7 +1278,7 @@ export class SqliteStore { // ─── Pattern Search (LIKE-based, for CJK text where FTS tokenization is weak) ─── - patternSearch(patterns: string[], opts: { role?: string; limit?: number } = {}): Array<{ chunkId: string; content: string; role: string; createdAt: number }> { + patternSearch(patterns: string[], opts: { role?: string; limit?: number; ownerFilter?: string[] } = {}): Array<{ chunkId: string; content: string; role: string; createdAt: number }> { if (patterns.length === 0) return []; const limit = opts.limit ?? 10; @@ -1216,13 +1287,21 @@ export class SqliteStore { const roleClause = opts.role ? " AND c.role = ?" : ""; const params: (string | number)[] = patterns.map(p => `%${p}%`); if (opts.role) params.push(opts.role); + + let ownerClause = ""; + if (opts.ownerFilter && opts.ownerFilter.length > 0) { + const placeholders = opts.ownerFilter.map(() => "?").join(","); + ownerClause = ` AND c.owner IN (${placeholders})`; + params.push(...opts.ownerFilter); + } + params.push(limit); try { const rows = this.db.prepare(` SELECT c.id as chunk_id, c.content, c.role, c.created_at FROM chunks c - WHERE (${whereClause})${roleClause} AND c.dedup_status = 'active' + WHERE (${whereClause})${roleClause}${ownerClause} AND c.dedup_status = 'active' ORDER BY c.created_at DESC LIMIT ? `).all(...params) as Array<{ chunk_id: string; content: string; role: string; created_at: number }>; @@ -1425,8 +1504,15 @@ export class SqliteStore { deleteAll(): number { this.db.exec("PRAGMA foreign_keys = OFF"); + try { + this.db.exec("DROP TRIGGER IF EXISTS tasks_fts_ai"); + this.db.exec("DROP TRIGGER IF EXISTS tasks_fts_ad"); + this.db.exec("DROP TRIGGER IF EXISTS tasks_fts_au"); + this.db.exec("DELETE FROM tasks_fts"); + } catch (_) {} const tables = [ "task_skills", + "task_embeddings", "skill_embeddings", "skill_versions", "skills", @@ -1449,6 +1535,7 @@ export class SqliteStore { } } this.db.exec("PRAGMA foreign_keys = ON"); + this.migrateTaskEmbeddingsAndFts(); const remaining = this.countChunks(); return remaining === 0 ? 1 : 0; } @@ -1469,6 +1556,21 @@ export class SqliteStore { return result.changes > 0; } + disableSkill(skillId: string): boolean { + const skill = this.getSkill(skillId); + if (!skill || skill.status === "archived") return false; + this.db.prepare("DELETE FROM skill_embeddings WHERE skill_id = ?").run(skillId); + this.updateSkill(skillId, { status: "archived", installed: 0 }); + return true; + } + + enableSkill(skillId: string): boolean { + const skill = this.getSkill(skillId); + if (!skill || skill.status !== "archived") return false; + this.updateSkill(skillId, { status: "active" }); + return true; + } + // ─── Task CRUD ─── insertTask(task: Task): void { @@ -1550,10 +1652,11 @@ export class SqliteStore { return rows.map(rowToChunk); } - listTasks(opts: { status?: string; limit?: number; offset?: number; owner?: string } = {}): { tasks: Task[]; total: number } { + listTasks(opts: { status?: string; limit?: number; offset?: number; owner?: string; session?: string } = {}): { tasks: Task[]; total: number } { const conditions: string[] = []; const params: unknown[] = []; if (opts.status) { conditions.push("status = ?"); params.push(opts.status); } + if (opts.session) { conditions.push("session_key = ?"); params.push(opts.session); } if (opts.owner) { conditions.push("(owner = ? OR (owner = 'public' AND id IN (SELECT task_id FROM local_shared_tasks WHERE original_owner = ?)))"); params.push(opts.owner, opts.owner); @@ -1675,9 +1778,24 @@ export class SqliteStore { this.db.prepare(`UPDATE skills SET ${sets.join(", ")} WHERE id = ?`).run(...params); } - listSkills(opts: { status?: string } = {}): Skill[] { - const cond = opts.status ? "WHERE status = ?" : ""; - const params = opts.status ? [opts.status] : []; + listSkills(opts: { status?: string; session?: string; owner?: string } = {}): Skill[] { + const conditions: string[] = []; + const params: unknown[] = []; + if (opts.status) { conditions.push("status = ?"); params.push(opts.status); } + if (opts.owner) { + conditions.push("(owner = ? OR owner = 'public')"); + params.push(opts.owner); + } + if (opts.session) { + conditions.push(`EXISTS ( + SELECT 1 + FROM task_skills ts + JOIN tasks t ON t.id = ts.task_id + WHERE ts.skill_id = skills.id AND t.session_key = ? + )`); + params.push(opts.session); + } + const cond = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const rows = this.db.prepare(`SELECT * FROM skills ${cond} ORDER BY updated_at DESC`).all(...params) as SkillRow[]; return rows.map(rowToSkill); } @@ -1767,6 +1885,61 @@ export class SqliteStore { } } + // ─── Task Embeddings & Search ─── + + upsertTaskEmbedding(taskId: string, vector: number[]): void { + const buf = Buffer.from(new Float32Array(vector).buffer); + this.db.prepare(` + INSERT OR REPLACE INTO task_embeddings (task_id, vector, dimensions, updated_at) + VALUES (?, ?, ?, ?) + `).run(taskId, buf, vector.length, Date.now()); + } + + getTaskEmbeddings(owner?: string): Array<{ taskId: string; vector: number[] }> { + let sql = `SELECT te.task_id, te.vector, te.dimensions + FROM task_embeddings te + JOIN tasks t ON t.id = te.task_id`; + const params: any[] = []; + if (owner) { + sql += ` WHERE (t.owner = ? OR t.owner = 'public')`; + params.push(owner); + } + const rows = this.db.prepare(sql).all(...params) as Array<{ task_id: string; vector: Buffer; dimensions: number }>; + return rows.map((r) => ({ + taskId: r.task_id, + vector: Array.from(new Float32Array(r.vector.buffer, r.vector.byteOffset, r.dimensions)), + })); + } + + taskFtsSearch(query: string, limit: number, owner?: string): Array<{ taskId: string; score: number }> { + const sanitized = sanitizeFtsQuery(query); + if (!sanitized) return []; + try { + let sql = ` + SELECT t.id as task_id, rank + FROM tasks_fts f + JOIN tasks t ON t.rowid = f.rowid + WHERE tasks_fts MATCH ?`; + const params: any[] = [sanitized]; + if (owner) { + sql += ` AND (t.owner = ? OR t.owner = 'public')`; + params.push(owner); + } + sql += ` ORDER BY rank LIMIT ?`; + params.push(limit); + const rows = this.db.prepare(sql).all(...params) as Array<{ task_id: string; rank: number }>; + if (rows.length === 0) return []; + const maxAbsRank = Math.max(...rows.map((r) => Math.abs(r.rank))); + return rows.map((r) => ({ + taskId: r.task_id, + score: maxAbsRank > 0 ? Math.abs(r.rank) / maxAbsRank : 0, + })); + } catch { + this.log.warn(`Task FTS query failed for: "${sanitized}", returning empty`); + return []; + } + } + listPublicSkills(): Skill[] { const rows = this.db.prepare("SELECT * FROM skills WHERE visibility = 'public' AND status = 'active' ORDER BY updated_at DESC").all() as SkillRow[]; return rows.map(rowToSkill); @@ -2430,9 +2603,10 @@ export class SqliteStore { upsertHubMemory(memory: HubMemoryRecord): void { this.db.prepare(` - INSERT INTO hub_memories (id, source_chunk_id, source_user_id, role, content, summary, kind, group_id, visibility, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO hub_memories (id, source_chunk_id, source_user_id, source_agent, role, content, summary, kind, group_id, visibility, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(source_user_id, source_chunk_id) DO UPDATE SET + source_agent = excluded.source_agent, role = excluded.role, content = excluded.content, summary = excluded.summary, @@ -2441,7 +2615,7 @@ export class SqliteStore { visibility = excluded.visibility, created_at = excluded.created_at, updated_at = excluded.updated_at - `).run(memory.id, memory.sourceChunkId, memory.sourceUserId, memory.role, memory.content, memory.summary, memory.kind, memory.groupId, memory.visibility, memory.createdAt, memory.updatedAt); + `).run(memory.id, memory.sourceChunkId, memory.sourceUserId, memory.sourceAgent, memory.role, memory.content, memory.summary, memory.kind, memory.groupId, memory.visibility, memory.createdAt, memory.updatedAt); } getHubMemoryBySource(sourceUserId: string, sourceChunkId: string): HubMemoryRecord | null { @@ -2550,6 +2724,11 @@ export class SqliteStore { this.db.prepare("UPDATE local_shared_tasks SET hub_task_id = '', hub_instance_id = '', visibility = 'public', group_id = NULL, synced_chunks = 0 WHERE task_id = ?").run(taskId); } + /** Client UI: remove team_shared_chunks rows for all chunks linked to this task (list badge chunk fallback). */ + clearTeamSharedChunksForTask(taskId: string): void { + this.db.prepare("DELETE FROM team_shared_chunks WHERE chunk_id IN (SELECT id FROM chunks WHERE task_id = ?)").run(taskId); + } + clearAllTeamSharingState(): void { this.clearTeamSharedChunks(); this.clearTeamSharedSkills(); @@ -2607,7 +2786,7 @@ export class SqliteStore { if (!sanitized) return []; const rows = this.db.prepare(` SELECT hm.id, hm.content, hm.summary, hm.role, hm.created_at, hm.visibility, '' as group_name, hu.username as owner_name, - bm25(hub_memories_fts) as rank + COALESCE(hm.source_agent, '') as source_agent, bm25(hub_memories_fts) as rank FROM hub_memories_fts f JOIN hub_memories hm ON hm.rowid = f.rowid LEFT JOIN hub_users hu ON hu.id = hm.source_user_id @@ -2623,7 +2802,7 @@ export class SqliteStore { getVisibleHubSearchHitByMemoryId(memoryId: string, userId: string): HubMemorySearchRow | null { const row = this.db.prepare(` SELECT hm.id, hm.content, hm.summary, hm.role, hm.created_at, hm.visibility, '' as group_name, hu.username as owner_name, - 0 as rank + COALESCE(hm.source_agent, '') as source_agent, 0 as rank FROM hub_memories hm LEFT JOIN hub_users hu ON hu.id = hm.source_user_id WHERE hm.id = ? @@ -3117,6 +3296,7 @@ export interface HubMemoryRecord { id: string; sourceChunkId: string; sourceUserId: string; + sourceAgent: string; role: string; content: string; summary: string; @@ -3131,6 +3311,7 @@ interface HubMemoryRow { id: string; source_chunk_id: string; source_user_id: string; + source_agent: string; role: string; content: string; summary: string; @@ -3146,6 +3327,7 @@ function rowToHubMemory(row: HubMemoryRow): HubMemoryRecord { id: row.id, sourceChunkId: row.source_chunk_id, sourceUserId: row.source_user_id, + sourceAgent: row.source_agent || "", role: row.role, content: row.content, summary: row.summary, @@ -3166,6 +3348,7 @@ interface HubMemorySearchRow { visibility: string; group_name: string | null; owner_name: string | null; + source_agent: string; rank: number; } diff --git a/apps/memos-local-openclaw/src/types.ts b/apps/memos-local-openclaw/src/types.ts index 88a853f09..cb08eb1cf 100644 --- a/apps/memos-local-openclaw/src/types.ts +++ b/apps/memos-local-openclaw/src/types.ts @@ -322,6 +322,8 @@ export interface MemosLocalConfig { skillEvolution?: SkillEvolutionConfig; telemetry?: TelemetryConfig; sharing?: SharingConfig; + /** Hours of inactivity after which an active task is automatically finalized. 0 = disabled. Default 4. */ + taskAutoFinalizeHours?: number; } // ─── Defaults ─── @@ -357,6 +359,7 @@ export const DEFAULTS = { skillAutoRecallLimit: 2, skillPreferUpgrade: true, skillRedactSensitive: true, + taskAutoFinalizeHours: 4, } as const; // ─── Plugin Hooks (OpenClaw integration) ─── diff --git a/apps/memos-local-openclaw/src/viewer/html.ts b/apps/memos-local-openclaw/src/viewer/html.ts index 14aecc6fc..5e4456e71 100644 --- a/apps/memos-local-openclaw/src/viewer/html.ts +++ b/apps/memos-local-openclaw/src/viewer/html.ts @@ -44,6 +44,7 @@ html{overflow-y:scroll} [data-theme="light"] .auth-card{box-shadow:0 25px 50px -12px rgba(0,0,0,.08)} [data-theme="light"] .topbar{background:rgba(255,255,255,.92);border-bottom-color:var(--border);backdrop-filter:blur(8px)} [data-theme="light"] .session-item .count,[data-theme="light"] .session-tag{background:rgba(0,0,0,.05)} +[data-theme="light"] .owner-tag{background:rgba(99,102,241,.08);border-color:rgba(99,102,241,.18)} [data-theme="light"] .card-content pre{background:#f3f4f6;border-color:var(--border)} [data-theme="light"] .vscore-badge{background:rgba(79,70,229,.06);color:#4f46e5} [data-theme="light"] ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15)} @@ -126,22 +127,24 @@ input,textarea,select{font-family:inherit;font-size:inherit} .main-content{display:flex;flex:1;max-width:1400px;margin:0 auto;width:100%;padding:28px 32px;gap:28px} /* ─── Sidebar ─── */ -.sidebar{width:260px;min-width:260px;flex-shrink:0} -.sidebar .stats-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:24px} -.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:18px;transition:all .2s} +.sidebar{width:260px;min-width:260px;flex-shrink:0;position:sticky;top:84px;max-height:calc(100vh - 112px);display:flex;flex-direction:column} +.sidebar > * {flex-shrink:0} +.sidebar .stats-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:20px} +.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;transition:all .2s;position:relative;overflow:hidden} +.stat-card::before{content:'';position:absolute;top:0;left:0;bottom:0;width:3px;border-radius:3px 0 0 3px;background:var(--border)} .stat-card:hover{border-color:var(--border-glow);background:var(--bg-card-hover)} -.stat-card .stat-value{font-size:22px;font-weight:700;color:var(--text);letter-spacing:-.02em} -.stat-card .stat-label{font-size:12px;color:var(--text-sec);margin-top:4px;font-weight:500} +.stat-card .stat-value{font-size:20px;font-weight:700;color:var(--text);letter-spacing:-.02em} +.stat-card .stat-label{font-size:11px;color:var(--text-sec);margin-top:2px;font-weight:500} +.stat-card.pri{border-left-color:transparent}.stat-card.pri::before{background:var(--pri)} .stat-card.pri .stat-value{color:var(--pri)} +.stat-card.green{border-left-color:transparent}.stat-card.green::before{background:var(--green)} .stat-card.green .stat-value{color:var(--green)} +.stat-card.amber{border-left-color:transparent}.stat-card.amber::before{background:var(--amber)} .stat-card.amber .stat-value{color:var(--amber)} +.stat-card.rose{border-left-color:transparent}.stat-card.rose::before{background:var(--rose)} .stat-card.rose .stat-value{color:var(--rose)} .sidebar .section-title{font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em;margin:24px 0 12px;padding:0 2px} -.sidebar .session-list{display:flex;flex-direction:column;gap:6px;max-height:280px;overflow-y:auto} -.session-item{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:all .15s;font-size:13px;color:var(--text)} -.session-item:hover{border-color:var(--pri);background:var(--pri-glow)} -.session-item.active{border-color:var(--pri);background:var(--pri-glow);font-weight:600;color:var(--pri)} .session-item .count{color:var(--text-sec);font-size:11px;font-weight:600;background:rgba(0,0,0,.2);padding:3px 8px;border-radius:8px} .provider-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--green-bg);color:var(--green);border-radius:999px;font-size:11px;font-weight:600;margin-top:10px} @@ -149,11 +152,12 @@ input,textarea,select{font-family:inherit;font-size:inherit} /* ─── Feed ─── */ .feed{flex:1;min-width:0} -.search-bar{display:flex;gap:10px;margin-bottom:16px;position:relative;align-items:center} +.search-bar{display:flex;gap:10px;margin-bottom:14px;position:relative;align-items:center} .search-bar input{flex:1;padding:10px 16px 10px 40px;border:1px solid var(--border);border-radius:10px;font-size:14px;outline:none;background:var(--bg-card);color:var(--text);transition:all .2s} .search-bar input::placeholder{color:var(--text-muted)} .search-bar input:focus{border-color:var(--pri);box-shadow:0 0 0 3px var(--pri-glow)} .search-bar .search-icon{position:absolute;left:14px;top:50%;transform:translateY(-50%);color:var(--text-muted);font-size:14px;pointer-events:none} +.search-bar .filter-select{padding:8px 14px;padding-right:30px;border-radius:10px;font-size:13px;background:var(--bg-card);flex-shrink:0} .search-meta{font-size:12px;color:var(--text-sec);padding:0 2px}.search-meta:not(:empty){margin-bottom:14px} .scope-select{padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-card);color:var(--text);font-size:13px;min-width:110px;outline:none} .sharing-inline-meta{font-size:12px;color:var(--text-muted);margin:-8px 0 14px 2px} @@ -370,13 +374,13 @@ input,textarea,select{font-family:inherit;font-size:inherit} .hub-source-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;background:rgba(34,197,94,.12);color:var(--green);font-size:11px;font-weight:700;border:1px solid rgba(34,197,94,.22)} @media (max-width: 960px){.sharing-settings-grid{grid-template-columns:1fr}.search-bar{flex-wrap:wrap}.scope-select{width:100%}.task-detail-actions{width:100%;justify-content:flex-start}} -.filter-bar{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap} -.filter-chip{padding:5px 14px;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text-sec);font-size:12px;font-weight:500;transition:all .15s} -.filter-chip:hover{border-color:var(--pri);color:var(--pri)} -.filter-chip.active{background:rgba(99,102,241,.08);color:var(--pri);border-color:rgba(99,102,241,.25)} +.filter-bar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center} +.filter-chip{padding:5px 14px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--text-sec);font-size:12px;font-weight:500;cursor:pointer;transition:all .15s} +.filter-chip:hover{border-color:var(--pri);color:var(--pri);background:rgba(99,102,241,.04)} +.filter-chip.active{background:rgba(99,102,241,.1);color:var(--pri);border-color:rgba(99,102,241,.3);font-weight:600} -.memory-list{display:flex;flex-direction:column;gap:16px} -.memory-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px 24px;transition:all .2s} +.memory-list{display:flex;flex-direction:column;gap:10px} +.memory-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;transition:all .2s} .memory-card:hover{border-color:var(--border-glow);background:var(--bg-card-hover)} .memory-card .card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap;gap:8px} .memory-card .meta{display:flex;align-items:center;gap:8px} @@ -385,13 +389,22 @@ input,textarea,select{font-family:inherit;font-size:inherit} .role-tag.assistant{background:var(--accent-glow);color:var(--accent);border:1px solid rgba(230,57,70,.2)} .role-tag.system{background:var(--amber-bg);color:var(--amber);border:1px solid rgba(245,158,11,.2)} .card-time{font-size:12px;color:var(--text-sec);display:flex;align-items:center;gap:8px} -.session-tag{font-size:11px;font-family:ui-monospace,monospace;color:var(--text-muted);background:rgba(0,0,0,.2);padding:3px 8px;border-radius:6px;cursor:default} +.session-tag{font-size:11px;font-family:ui-monospace,monospace;color:var(--text-muted);background:rgba(0,0,0,.2);padding:3px 8px;border-radius:6px;cursor:pointer} +.session-tag:hover{filter:brightness(1.12)} +.owner-tag{font-size:11px;font-weight:600;color:var(--pri);background:var(--pri-glow);padding:3px 9px;border-radius:8px;border:1px solid rgba(99,102,241,.15);cursor:default;white-space:nowrap} .card-summary{font-size:15px;font-weight:600;color:var(--text);margin-bottom:10px;line-height:1.5;letter-spacing:-.01em;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} .card-content{font-size:13px;color:var(--text-sec);line-height:1.65;max-height:0;overflow:hidden;transition:max-height .3s ease} .card-content.show{max-height:600px;overflow-y:auto} .card-content pre{white-space:pre-wrap;word-break:break-all;background:rgba(0,0,0,.25);padding:14px;border-radius:10px;font-size:12px;font-family:ui-monospace,monospace;margin-top:10px;border:1px solid var(--border);color:var(--text-sec)} .card-actions{display:flex;align-items:center;gap:8px;margin-top:14px} .card-actions-inline{display:inline-flex;align-items:center;gap:4px;margin-left:auto;flex-shrink:0} +.btn-warn{color:#f59e0b !important} +.btn-warn:hover{background:rgba(245,158,11,.15) !important} +.btn-danger{color:#ef4444 !important} +.btn-danger:hover{background:rgba(239,68,68,.15) !important} +.btn-success{color:#10b981 !important} +.btn-success:hover{background:rgba(16,185,129,.15) !important} +.skill-card.archived{opacity:0.55;border-style:dashed} .vscore-badge{display:inline-flex;align-items:center;background:rgba(59,130,246,.15);color:#60a5fa;font-size:10px;font-weight:700;padding:4px 10px;border-radius:8px;margin-left:auto} .merge-badge{display:inline-flex;align-items:center;gap:4px;background:rgba(16,185,129,.12);color:#10b981;font-size:10px;font-weight:600;padding:3px 10px;border-radius:8px} .merge-history{margin-top:12px;padding:12px 14px;background:rgba(0,0,0,.15);border-radius:10px;border:1px solid var(--border);font-size:12px;line-height:1.7;color:var(--text-sec);max-height:200px;overflow-y:auto} @@ -573,32 +586,39 @@ input,textarea,select{font-family:inherit;font-size:inherit} ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15);border-radius:3px} ::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.25)} -.filter-sep{width:1px;height:20px;background:var(--border);margin:0 4px} -.filter-select{padding:6px 12px;border:1px solid var(--border);border-radius:999px;background:var(--bg-card);color:var(--text-sec);font-size:13px;outline:none;cursor:pointer} -.filter-select:focus{border-color:var(--pri)} +.filter-sep{width:1px;height:20px;background:var(--border);margin:0 2px} +.filter-select{padding:5px 14px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--text-sec);font-size:12px;font-weight:500;outline:none;cursor:pointer;transition:all .15s;-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7280'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;padding-right:28px} +.filter-select:hover{border-color:var(--pri);color:var(--pri)} +.filter-select:focus{border-color:var(--pri);color:var(--pri);background-color:rgba(99,102,241,.04)} .date-filter{display:flex;align-items:center;gap:10px;margin-bottom:18px;font-size:13px;color:var(--text-sec)} -.date-filter input[type="datetime-local"]{padding:6px 10px;border:1px solid var(--border);border-radius:8px;font-size:12px;outline:none;background:var(--bg-card);color:var(--text)} +.date-filter input[type="datetime-local"]{padding:5px 12px;border:1px solid var(--border);border-radius:8px;font-size:12px;outline:none;background:transparent;color:var(--text-sec);transition:all .15s} .date-filter input[type="datetime-local"]:focus{border-color:var(--pri)} .date-filter label{font-weight:500} - -.pagination{display:flex;align-items:center;justify-content:center;gap:6px;padding:28px 0;flex-wrap:wrap} -.pagination .pg-btn{min-width:38px;height:38px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:10px;background:var(--bg-card);color:var(--text-sec);font-size:13px;font-weight:500;cursor:pointer;transition:all .15s} -.pagination .pg-btn:hover{border-color:var(--pri);color:var(--pri)} -.pagination .pg-btn.active{background:var(--pri);color:#000;border-color:var(--pri)} -.pagination .pg-btn.disabled{opacity:.4;pointer-events:none} -.pagination .pg-info{font-size:12px;color:var(--text-sec);padding:0 12px} +.compact-filter-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.compact-date{padding:5px 12px;border:1px solid var(--border);border-radius:8px;font-size:12px;font-weight:500;outline:none;background:transparent;color:var(--text-sec);max-width:180px;transition:all .15s} +.compact-date:hover{border-color:var(--pri);color:var(--pri)} +.compact-date:focus{border-color:var(--pri);color:var(--pri);background:rgba(99,102,241,.04)} + +.pagination-row{display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;padding:16px 0 8px} +.pagination{display:flex;align-items:center;justify-content:center;gap:4px;padding:0;flex-wrap:wrap} +.pagination .pg-btn{min-width:32px;height:32px;display:flex;align-items:center;justify-content:center;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-sec);font-size:12px;font-weight:500;cursor:pointer;transition:all .15s} +.pagination .pg-btn:hover{background:rgba(99,102,241,.06);color:var(--pri);border-color:rgba(99,102,241,.15)} +.pagination .pg-btn.active{background:var(--pri-grad);color:#fff;border-color:transparent;box-shadow:0 2px 8px rgba(99,102,241,.3)} +.pagination .pg-btn.disabled{opacity:.3;pointer-events:none} +.pagination .pg-info{font-size:11px;color:var(--text-muted);padding:0 8px} /* ─── Tasks 视图 ─── */ .view-container{flex:1;min-width:0} .view-container>.vp{display:none;flex-direction:column} .view-container>.vp.show{display:flex} -.tasks-view{flex:1;min-width:0;flex-direction:column;gap:16px} -.tasks-header{display:flex;flex-direction:column;gap:14px} -.tasks-stats{display:flex;gap:16px} -.tasks-stat{display:flex;align-items:center;gap:8px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 18px;flex:1;transition:all .2s} +.tasks-view{flex:1;min-width:0;flex-direction:column} +.tasks-header{display:flex;flex-direction:column;gap:0} +.tasks-stats{display:flex;gap:10px} +.tasks-stat{display:flex;align-items:center;gap:8px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:10px 16px;flex:1;transition:all .2s;position:relative;overflow:hidden} +.tasks-stat::before{content:'';position:absolute;top:0;left:0;bottom:0;width:3px;border-radius:3px 0 0 3px} .tasks-stat:hover{border-color:var(--border-glow)} -.tasks-stat-value{font-size:22px;font-weight:700;color:var(--text)} -.tasks-stat-label{font-size:12px;color:var(--text-sec);font-weight:500} +.tasks-stat-value{font-size:18px;font-weight:700;color:var(--text)} +.tasks-stat-label{font-size:11px;color:var(--text-sec);font-weight:500} .tasks-filters{display:flex;align-items:center;gap:6px;flex-wrap:wrap} .tasks-list{display:flex;flex-direction:column;gap:10px} .task-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:18px 20px;cursor:pointer;transition:all .25s;position:relative;overflow:hidden} @@ -631,7 +651,7 @@ input,textarea,select{font-family:inherit;font-size:inherit} .task-detail-meta{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:20px;font-size:12px;color:var(--text-sec)} .task-detail-meta .meta-item{display:flex;align-items:center;gap:5px;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:5px 12px} .task-detail-summary{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:20px;font-size:13px;line-height:1.7;color:var(--text);word-break:break-word} -.task-detail-summary:empty::after{content:'Summary not yet generated (task still active)';color:var(--text-muted);font-style:italic} +#taskDetailSummary:empty::after{content:'Summary not yet generated (task still active)';color:var(--text-muted);font-style:italic} .task-detail-summary .summary-section-title{font-size:14px;font-weight:700;color:var(--text);margin:14px 0 6px 0;padding-bottom:4px;border-bottom:1px solid var(--border)} .task-detail-summary .summary-section-title:first-child{margin-top:0} .task-detail-summary ul{margin:4px 0 8px 0;padding-left:20px} @@ -670,7 +690,7 @@ input,textarea,select{font-family:inherit;font-size:inherit} [data-theme="light"] .tasks-stat{background:#fff} /* ─── Skills ─── */ -.skills-view{flex:1;min-width:0;flex-direction:column;gap:16px} +.skills-view{flex:1;min-width:0;flex-direction:column} .skill-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:18px 20px;cursor:pointer;transition:all .25s;position:relative;overflow:hidden} .skill-card:hover{border-color:var(--border-glow);background:var(--bg-card-hover);transform:translateY(-1px);box-shadow:var(--shadow)} .skill-card::before{content:'';position:absolute;top:0;left:0;bottom:0;width:3px;border-radius:3px 0 0 3px;background:var(--violet)} @@ -697,6 +717,10 @@ input,textarea,select{font-family:inherit;font-size:inherit} .skill-card-bottom .tag{display:flex;align-items:center;gap:4px} .skill-card-tags{display:flex;gap:4px;flex-wrap:wrap} .skill-tag{font-size:10px;padding:2px 8px;border-radius:10px;background:rgba(139,92,246,.1);color:var(--violet);font-weight:500} +.selection-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +#memorySelectionToolbar{margin-bottom:16px} +.item-select-box{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:5px;border:1px solid var(--border);background:var(--bg-card);cursor:pointer;margin-right:8px;vertical-align:middle} +.item-select-box input{width:14px;height:14px;cursor:pointer} .skill-detail-desc{font-size:13px;color:var(--text-sec);line-height:1.6;margin-bottom:16px;padding:12px 16px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius)} .skill-version-item{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px} .skill-version-header{display:flex;align-items:center;gap:10px;margin-bottom:6px} @@ -808,6 +832,7 @@ input,textarea,select{font-family:inherit;font-size:inherit} .recall-origin.local-shared{background:rgba(59,130,246,.12);color:#3b82f6} .recall-origin.hub-memory{background:rgba(139,92,246,.12);color:#8b5cf6} .recall-origin.hub-remote{background:rgba(139,92,246,.12);color:#8b5cf6} +.recall-origin.agent-tag{background:rgba(20,184,166,.12);color:#14b8a6} .recall-summary-short{flex:1;color:var(--text-sec);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .recall-expand-icon{flex-shrink:0;font-size:10px;color:var(--text-muted);transition:transform .15s} .recall-item.expanded .recall-expand-icon{transform:rotate(90deg)} @@ -1225,8 +1250,8 @@ input,textarea,select{font-family:inherit;font-size:inherit}
- - +
@@ -1251,14 +1275,13 @@ input,textarea,select{font-family:inherit;font-size:inherit}
-
-
-
+ + +
@@ -1267,42 +1290,48 @@ input,textarea,select{font-family:inherit;font-size:inherit} - - + + +
-
- - - +
+ + + +
- +
+ +
-
-
-
-Total Tasks
-
-Active
-
-Completed
-
-Skipped
-
-
- - - - - -
+ + +
+ + + + + + +
- +
+ +
@@ -1336,34 +1365,33 @@ input,textarea,select{font-family:inherit;font-size:inherit} - -
-
-
-Total Skills
-
-Active
-
-Draft
-
-Installed
-
-Public
-
-
- - - - - -
+ +
+ + + + + + + +
+
+ +
+
+ + +
Active tasks with no new messages beyond this duration will be automatically summarized and completed when the Tasks page is opened. Set to 0 to disable. Default: 4 hours.
+
@@ -1871,18 +1904,20 @@ input,textarea,select{font-family:inherit;font-size:inherit}
-
+
- - +