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`)