diff --git a/agent/tools/memory/memory_get.py b/agent/tools/memory/memory_get.py index 64e4d4de..1bd4828e 100644 --- a/agent/tools/memory/memory_get.py +++ b/agent/tools/memory/memory_get.py @@ -44,6 +44,19 @@ class MemoryGetTool(BaseTool): """ super().__init__() self.memory_manager = memory_manager + + from config import conf + if conf().get("knowledge", True): + self.description = ( + "Read specific content from memory or knowledge files. " + "Use this to get full context from a memory file, knowledge page, or specific line range." + ) + self.params = {**self.params} + self.params["properties"] = {**self.params["properties"]} + self.params["properties"]["path"] = { + "type": "string", + "description": "Relative path to the memory or knowledge file (e.g. 'MEMORY.md', 'memory/2026-01-01.md', 'knowledge/concepts/moe.md')" + } def execute(self, args: dict): """ @@ -68,8 +81,8 @@ class MemoryGetTool(BaseTool): workspace_dir = self.memory_manager.config.get_workspace() # Auto-prepend memory/ if not present and not absolute path - # Exception: MEMORY.md is in the root directory - if not path.startswith('memory/') and not path.startswith('/') and path != 'MEMORY.md': + # Exceptions: MEMORY.md in root, knowledge/ files at workspace root + if not path.startswith('memory/') and not path.startswith('knowledge/') and not path.startswith('/') and path != 'MEMORY.md': path = f'memory/{path}' file_path = workspace_dir / path diff --git a/agent/tools/memory/memory_search.py b/agent/tools/memory/memory_search.py index d7b14df3..1387d11c 100644 --- a/agent/tools/memory/memory_search.py +++ b/agent/tools/memory/memory_search.py @@ -48,6 +48,13 @@ class MemorySearchTool(BaseTool): super().__init__() self.memory_manager = memory_manager self.user_id = user_id + + from config import conf + if conf().get("knowledge", True): + self.description = ( + "Search agent's long-term memory and knowledge base using semantic and keyword search. " + "Use this to recall past conversations, preferences, and knowledge pages." + ) def execute(self, args: dict): """ diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 560d4712..33a3f484 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -1038,6 +1038,7 @@ function startSSE(requestId, loadingEl, timestamp) { // Only update text content when there is something new to show. if (finalText) contentEl.innerHTML = renderMarkdown(finalText); applyHighlighting(botEl); + bindChatKnowledgeLinks(botEl); } scrollChatToBottom(); @@ -1255,6 +1256,7 @@ function createBotMessageEl(content, timestamp, requestId, msg) { `; applyHighlighting(el); + bindChatKnowledgeLinks(el); return el; } @@ -3002,6 +3004,92 @@ function filterKnowledgeTree(query) { renderKnowledgeTree(_knowledgeTreeData, query); } +function resolveKnowledgePath(currentFilePath, relativeHref) { + // currentFilePath: e.g. "concepts/mcp-protocol.md" + // relativeHref: e.g. "../entities/openai.md" + const parts = currentFilePath.split('/'); + parts.pop(); // remove filename, keep directory + const segments = [...parts, ...relativeHref.split('/')]; + const resolved = []; + for (const seg of segments) { + if (seg === '..') resolved.pop(); + else if (seg !== '.' && seg !== '') resolved.push(seg); + } + return resolved.join('/'); +} + +function bindKnowledgeLinks(container, currentFilePath) { + container.querySelectorAll('a').forEach(a => { + const href = a.getAttribute('href'); + if (!href || !href.endsWith('.md')) return; + // Skip absolute URLs + if (/^https?:\/\//.test(href)) return; + + a.addEventListener('click', (e) => { + e.preventDefault(); + const resolved = resolveKnowledgePath(currentFilePath, href); + const linkTitle = a.textContent.trim() || resolved.replace(/\.md$/, '').split('/').pop(); + openKnowledgeFile(resolved, linkTitle); + }); + a.style.cursor = 'pointer'; + a.classList.add('text-primary-500', 'hover:underline'); + }); +} + +function bindChatKnowledgeLinks(container) { + if (!container) return; + container.querySelectorAll('a').forEach(a => { + const href = a.getAttribute('href'); + if (!href || !href.endsWith('.md')) return; + if (/^https?:\/\//.test(href)) return; + + // Determine knowledge path + let knowledgePath = null; + if (href.startsWith('knowledge/')) { + // Full path from workspace root: knowledge/concepts/moe.md + knowledgePath = href.replace(/^knowledge\//, ''); + } else if (/^[a-z0-9_-]+\/[a-z0-9_.-]+\.md$/i.test(href)) { + // Looks like category/file.md pattern without knowledge/ prefix + knowledgePath = href; + } else if (href.includes('/') && !href.startsWith('/')) { + // Relative path like ../entities/deepseek.md — extract filename and search + const filename = href.split('/').pop(); + knowledgePath = '__search__:' + filename; + } + if (!knowledgePath) return; + + a.addEventListener('click', (e) => { + e.preventDefault(); + if (knowledgePath.startsWith('__search__:')) { + const filename = knowledgePath.replace('__search__:', ''); + // Find the file in cached tree data + const found = _findKnowledgeFileByName(filename); + if (found) { + navigateTo('knowledge'); + setTimeout(() => openKnowledgeFile(found.path, found.title), 100); + } + } else { + navigateTo('knowledge'); + const linkTitle = a.textContent.trim() || knowledgePath.replace(/\.md$/, '').split('/').pop(); + setTimeout(() => openKnowledgeFile(knowledgePath, linkTitle), 100); + } + }); + a.style.cursor = 'pointer'; + a.classList.add('text-primary-500', 'hover:underline'); + }); +} + +function _findKnowledgeFileByName(filename) { + for (const group of _knowledgeTreeData) { + for (const f of group.files) { + if (f.name === filename) { + return { path: group.dir + '/' + f.name, title: f.title }; + } + } + } + return null; +} + function openKnowledgeFile(path, title) { _knowledgeCurrentFile = path; // Update active state in tree via data-path @@ -3017,9 +3105,11 @@ function openKnowledgeFile(path, title) { const viewer = document.getElementById('knowledge-content-viewer'); document.getElementById('knowledge-viewer-title').textContent = title; document.getElementById('knowledge-viewer-path').textContent = path; - document.getElementById('knowledge-viewer-body').innerHTML = renderMarkdown(data.content || ''); + const bodyEl = document.getElementById('knowledge-viewer-body'); + bodyEl.innerHTML = renderMarkdown(data.content || ''); viewer.classList.remove('hidden'); applyHighlighting(viewer); + bindKnowledgeLinks(bodyEl, path); // Mobile: hide sidebar, show content if (window.innerWidth < 768) { @@ -3234,6 +3324,12 @@ function renderKnowledgeGraph(container, nodes, links) { // ===================================================================== applyTheme(); applyI18n(); + +// Pre-fetch knowledge tree for chat link resolution +fetch('/api/knowledge/list').then(r => r.json()).then(data => { + if (data.status === 'success') _knowledgeTreeData = data.tree || []; +}).catch(() => {}); + fetch('/api/version').then(r => r.json()).then(data => { APP_VERSION = `v${data.version}`; document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`; diff --git a/skills/knowledge-wiki/SKILL.md b/skills/knowledge-wiki/SKILL.md index b72a2114..508441ac 100644 --- a/skills/knowledge-wiki/SKILL.md +++ b/skills/knowledge-wiki/SKILL.md @@ -88,3 +88,4 @@ Append-only, newest at bottom: - **Cross-reference**: every page should link to related pages; keep the knowledge graph connected - **Index is mandatory**: always update `knowledge/index.md` after any change - **Be concise**: capture essence, not copy entire sources +- **Full paths in replies**: when referencing knowledge files in conversation replies, use the full path from workspace root (e.g. `[Title](knowledge//.md)`), not relative paths. Relative paths are only for cross-references inside knowledge pages themselves.