diff --git a/agent/knowledge/service.py b/agent/knowledge/service.py index a4fc3d6b..a9425385 100644 --- a/agent/knowledge/service.py +++ b/agent/knowledge/service.py @@ -34,7 +34,8 @@ class KnowledgeService: # ------------------------------------------------------------------ def list_tree(self) -> dict: """ - Return the knowledge directory tree grouped by category. + Return the knowledge directory tree grouped by category, + supporting arbitrarily nested sub-directories. Returns:: @@ -44,10 +45,20 @@ class KnowledgeService: "dir": "concepts", "files": [ {"name": "moe.md", "title": "MoE", "size": 1234}, - ... + ], + "children": [] + }, + { + "dir": "platform", + "files": [], + "children": [ + { + "dir": "analysis", + "files": [{"name": "perf.md", ...}], + "children": [] + } ] }, - ... ], "stats": {"pages": 15, "size": 32768}, "enabled": true @@ -56,37 +67,43 @@ class KnowledgeService: if not os.path.isdir(self.knowledge_dir): return {"tree": [], "stats": {"pages": 0, "size": 0}, "enabled": conf().get("knowledge", True)} - tree = [] - total_files = 0 - total_bytes = 0 - for name in sorted(os.listdir(self.knowledge_dir)): - full = os.path.join(self.knowledge_dir, name) - if not os.path.isdir(full) or name.startswith("."): - continue - files = [] - for fname in sorted(os.listdir(full)): - if fname.endswith(".md") and not fname.startswith("."): - fpath = os.path.join(full, fname) - size = os.path.getsize(fpath) - total_files += 1 - total_bytes += size - title = fname.replace(".md", "") - try: - with open(fpath, "r", encoding="utf-8") as f: - first_line = f.readline().strip() - if first_line.startswith("# "): - title = first_line[2:].strip() - except Exception: - pass - files.append({"name": fname, "title": title, "size": size}) - tree.append({"dir": name, "files": files}) + stats = {"pages": 0, "size": 0} + tree = self._scan_dir(self.knowledge_dir, stats) return { "tree": tree, - "stats": {"pages": total_files, "size": total_bytes}, + "stats": stats, "enabled": conf().get("knowledge", True), } + def _scan_dir(self, dir_path: str, stats: dict) -> list: + """Recursively scan a directory and return list of group nodes.""" + result = [] + for name in sorted(os.listdir(dir_path)): + if name.startswith("."): + continue + full = os.path.join(dir_path, name) + if os.path.isdir(full): + files = [] + for fname in sorted(os.listdir(full)): + fpath = os.path.join(full, fname) + if os.path.isfile(fpath) and fname.endswith(".md") and not fname.startswith("."): + size = os.path.getsize(fpath) + stats["pages"] += 1 + stats["size"] += size + title = fname.replace(".md", "") + try: + with open(fpath, "r", encoding="utf-8") as f: + first_line = f.readline().strip() + if first_line.startswith("# "): + title = first_line[2:].strip() + except Exception: + pass + files.append({"name": fname, "title": title, "size": size}) + children = self._scan_dir(full, stats) + result.append({"dir": name, "files": files, "children": children}) + return result + # ------------------------------------------------------------------ # read — single file content # ------------------------------------------------------------------ diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css index 2cf47449..7f8b594c 100644 --- a/channel/web/static/css/console.css +++ b/channel/web/static/css/console.css @@ -952,13 +952,13 @@ font-size: 8px; transition: transform 0.15s; } -.knowledge-tree-group.open .chevron { +.knowledge-tree-group.open > .knowledge-tree-group-btn .chevron { transform: rotate(90deg); } .knowledge-tree-group-items { display: none; } -.knowledge-tree-group.open .knowledge-tree-group-items { +.knowledge-tree-group.open > .knowledge-tree-group-items { display: block; } diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 8ec9e469..61cf9bda 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -3620,19 +3620,28 @@ function renderKnowledgeTree(tree, filter) { const container = document.getElementById('knowledge-tree'); container.innerHTML = ''; const lowerFilter = (filter || '').toLowerCase(); + _renderKnowledgeGroups(container, tree, '', lowerFilter, 0); +} - tree.forEach(group => { - const files = group.files.filter(f => +function _renderKnowledgeGroups(container, groups, parentPath, lowerFilter, depth) { + const indent = depth * 12; + groups.forEach(group => { + const groupPath = parentPath ? parentPath + '/' + group.dir : group.dir; + const files = (group.files || []).filter(f => !lowerFilter || f.title.toLowerCase().includes(lowerFilter) || f.name.toLowerCase().includes(lowerFilter) ); - if (files.length === 0 && lowerFilter) return; + const children = group.children || []; + const hasMatchingChildren = lowerFilter ? _hasFilterMatch(children, lowerFilter) : children.length > 0; + if (files.length === 0 && !hasMatchingChildren && lowerFilter) return; const div = document.createElement('div'); div.className = 'knowledge-tree-group open'; + const fileCount = _countFiles(group); const btn = document.createElement('button'); btn.className = 'knowledge-tree-group-btn'; - btn.innerHTML = `${escapeHtml(group.dir)}${files.length}`; + btn.style.paddingLeft = (8 + indent) + 'px'; + btn.innerHTML = `${escapeHtml(group.dir)}${fileCount}`; btn.onclick = () => div.classList.toggle('open'); div.appendChild(btn); @@ -3640,18 +3649,40 @@ function renderKnowledgeTree(tree, filter) { items.className = 'knowledge-tree-group-items'; files.forEach(f => { const fbtn = document.createElement('button'); - const fpath = group.dir + '/' + f.name; + const fpath = groupPath + '/' + f.name; fbtn.className = 'knowledge-tree-file' + (_knowledgeCurrentFile === fpath ? ' active' : ''); fbtn.dataset.path = fpath; + fbtn.style.paddingLeft = (24 + indent) + 'px'; fbtn.innerHTML = `${escapeHtml(f.title)}`; fbtn.onclick = () => openKnowledgeFile(fpath, f.title); items.appendChild(fbtn); }); + if (children.length > 0) { + _renderKnowledgeGroups(items, children, groupPath, lowerFilter, depth + 1); + } div.appendChild(items); container.appendChild(div); }); } +function _hasFilterMatch(groups, lowerFilter) { + for (const g of groups) { + for (const f of (g.files || [])) { + if (f.title.toLowerCase().includes(lowerFilter) || f.name.toLowerCase().includes(lowerFilter)) return true; + } + if (_hasFilterMatch(g.children || [], lowerFilter)) return true; + } + return false; +} + +function _countFiles(group) { + let count = (group.files || []).length; + for (const child of (group.children || [])) { + count += _countFiles(child); + } + return count; +} + function filterKnowledgeTree(query) { renderKnowledgeTree(_knowledgeTreeData, query); } @@ -3732,12 +3763,19 @@ function bindChatKnowledgeLinks(container) { } function _findKnowledgeFileByName(filename) { - for (const group of _knowledgeTreeData) { - for (const f of group.files) { + return _searchFileInGroups(_knowledgeTreeData, '', filename); +} + +function _searchFileInGroups(groups, parentPath, filename) { + for (const group of groups) { + const groupPath = parentPath ? parentPath + '/' + group.dir : group.dir; + for (const f of (group.files || [])) { if (f.name === filename) { - return { path: group.dir + '/' + f.name, title: f.title }; + return { path: groupPath + '/' + f.name, title: f.title }; } } + const found = _searchFileInGroups(group.children || [], groupPath, filename); + if (found) return found; } return null; } diff --git a/skills/knowledge-wiki/SKILL.md b/skills/knowledge-wiki/SKILL.md index 1b0123da..9fdf5f5b 100644 --- a/skills/knowledge-wiki/SKILL.md +++ b/skills/knowledge-wiki/SKILL.md @@ -40,6 +40,8 @@ Maintain a persistent, structured knowledge base in the `knowledge/` directory. ```markdown # Page Title +> Source: + Content here. Cross-reference related pages with markdown links: [Related Page](../category/related-page.md) @@ -53,6 +55,8 @@ Content here. Cross-reference related pages with markdown links: - [Page B](../category/page-b.md) — how it relates ``` +The `> Source:` line records where the knowledge came from (URL, document name, conversation, etc.). Always include it when the material originates from a specific source. + Cross-references build a knowledge graph. When creating or updating a page, link to related pages and update those pages to link back. **Only link to pages that already exist** — if a concept deserves its own page, create it first, then add the link. ## Index Format (`knowledge/index.md`)