| """ |
| Knowledge Base Filesystem Interface. |
| |
| Provides a filesystem-like command interface (ls, cat, grep, find, etc.) |
| for AI models to interact with knowledge bases using commands they already know. |
| |
| Re-exported through builtin.py for consistent imports. |
| """ |
|
|
| import json |
| import logging |
| import re |
| import shlex |
| import time |
| from typing import Optional |
|
|
| from fastapi import Request |
|
|
| log = logging.getLogger(__name__) |
|
|
| |
| MAX_CAT_CHARS = 100_000 |
| DEFAULT_CAT_CHARS = 10_000 |
| MAX_GREP_FILES = 200 |
| DEFAULT_HEAD_LINES = 10 |
| DEFAULT_TAIL_LINES = 10 |
| MAX_GREP_MATCHES = 50 |
|
|
|
|
| |
| |
| |
|
|
|
|
| def is_regex_pattern(pattern: str) -> bool: |
| """Detect if a pattern looks like regex (\|, .*, .+, \d, \w, \s, [...]).""" |
| return ( |
| '\|' in pattern |
| or '.*' in pattern |
| or '.+' in pattern |
| or '.?' in pattern |
| or '\d' in pattern |
| or '\w' in pattern |
| or '\s' in pattern |
| or bool(re.search(r'\[.+\]', pattern)) |
| ) |
|
|
|
|
| def normalize_regex(pattern: str) -> str: |
| """Normalize POSIX BRE patterns to Python regex (\| β |).""" |
| return pattern.replace('\\|', '|').replace('\|', '|') |
|
|
|
|
| def build_matcher(pattern: str, case_insensitive: bool = False, use_regex: bool = False) -> tuple: |
| """Build a matcher function. Returns (match_fn, error_str_or_None).""" |
| if not use_regex and is_regex_pattern(pattern): |
| use_regex = True |
|
|
| if use_regex: |
| normalized = normalize_regex(pattern) |
| try: |
| re_flags = re.IGNORECASE if case_insensitive else 0 |
| compiled = re.compile(normalized, re_flags) |
| except re.error as e: |
| return None, f'Invalid regex: {e}' |
| return (lambda line: bool(compiled.search(line))), None |
| else: |
| sp = pattern.lower() if case_insensitive else pattern |
| return (lambda line: sp in (line.lower() if case_insensitive else line)), None |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _parse_pipeline(command: str) -> list[list[str]]: |
| """Split command on pipes, then tokenize each segment.""" |
| |
| segments = [] |
| current = [] |
| in_single = False |
| in_double = False |
| buf = [] |
|
|
| for ch in command: |
| if ch == "'" and not in_double: |
| in_single = not in_single |
| buf.append(ch) |
| elif ch == '"' and not in_single: |
| in_double = not in_double |
| buf.append(ch) |
| elif ch == '|' and not in_single and not in_double: |
| segments.append(''.join(buf).strip()) |
| buf = [] |
| else: |
| buf.append(ch) |
|
|
| remaining = ''.join(buf).strip() |
| if remaining: |
| segments.append(remaining) |
|
|
| result = [] |
| for seg in segments: |
| if not seg: |
| continue |
| try: |
| tokens = shlex.split(seg) |
| except ValueError: |
| |
| tokens = seg.split() |
| if tokens: |
| result.append(tokens) |
|
|
| return result |
|
|
|
|
| def _extract_flags(tokens: list[str]) -> tuple[set[str], list[str]]: |
| """Extract single-char flags (e.g. -i, -l, -c, -n, -la) from tokens. |
| |
| Returns (flags_set, remaining_args). |
| """ |
| flags = set() |
| args = [] |
| for token in tokens: |
| if token.startswith('-') and len(token) > 1 and not token[1:].isdigit(): |
| |
| for ch in token[1:]: |
| flags.add(ch) |
| else: |
| args.append(token) |
| return flags, args |
|
|
|
|
| def _extract_numeric_flag(tokens: list[str]) -> tuple[Optional[int], list[str]]: |
| """Extract a numeric flag like -20 from tokens. Returns (number, remaining).""" |
| num = None |
| remaining = [] |
| for token in tokens: |
| if num is None and re.match(r'^-\d+$', token): |
| num = int(token[1:]) |
| else: |
| remaining.append(token) |
| return num, remaining |
|
|
|
|
| |
| |
| |
|
|
|
|
| async def _build_directory_tree(knowledge_id: str) -> dict: |
| """Build an in-memory directory tree for a KB. Returns {dirs, files, path_to_dir_id, dir_id_to_path}.""" |
| from open_webui.models.knowledge import Knowledges |
|
|
| all_dirs = await Knowledges.get_all_directories(knowledge_id) |
| files_with_dirs = await Knowledges.get_files_with_directory_ids(knowledge_id) |
|
|
| |
| dir_map = {} |
| for d in all_dirs: |
| dir_map[d.id] = {'name': d.name, 'parent_id': d.parent_id, 'id': d.id} |
|
|
| |
| dir_id_to_path = {} |
|
|
| def _get_dir_path(dir_id): |
| if dir_id in dir_id_to_path: |
| return dir_id_to_path[dir_id] |
| d = dir_map.get(dir_id) |
| if not d: |
| return '' |
| if d['parent_id'] and d['parent_id'] in dir_map: |
| parent_path = _get_dir_path(d['parent_id']) |
| path = f'{parent_path}/{d["name"]}' if parent_path else d['name'] |
| else: |
| path = d['name'] |
| dir_id_to_path[dir_id] = path |
| return path |
|
|
| for d_id in dir_map: |
| _get_dir_path(d_id) |
|
|
| path_to_dir_id = {v: k for k, v in dir_id_to_path.items()} |
|
|
| |
| files = [] |
| for file_model, directory_id in files_with_dirs: |
| if directory_id and directory_id in dir_id_to_path: |
| file_path = f'{dir_id_to_path[directory_id]}/{file_model.filename}' |
| else: |
| file_path = file_model.filename |
| files.append( |
| { |
| 'id': file_model.id, |
| 'filename': file_model.filename, |
| 'path': file_path, |
| 'directory_id': directory_id, |
| 'size': file_model.meta.get('size') if file_model.meta else None, |
| 'type': file_model.meta.get('content_type') if file_model.meta else None, |
| 'updated_at': file_model.updated_at, |
| } |
| ) |
|
|
| return { |
| 'dirs': dir_map, |
| 'files': files, |
| 'path_to_dir_id': path_to_dir_id, |
| 'dir_id_to_path': dir_id_to_path, |
| } |
|
|
|
|
| def _resolve_path(path: str, tree: dict) -> str | None: |
| """Resolve a directory path string to a dir_id. Returns None if not found.""" |
| path = path.strip('/') |
| return tree['path_to_dir_id'].get(path) |
|
|
|
|
| def _get_files_in_dir(tree: dict, dir_id: str | None) -> list[dict]: |
| """Get files directly in a directory (None = root).""" |
| return [f for f in tree['files'] if f['directory_id'] == dir_id] |
|
|
|
|
| def _get_subdirs(tree: dict, parent_id: str | None) -> list[dict]: |
| """Get immediate child directories.""" |
| return sorted([d for d in tree['dirs'].values() if d['parent_id'] == parent_id], key=lambda d: d['name']) |
|
|
|
|
| def _get_files_under_dir(tree: dict, dir_id: str) -> list[dict]: |
| """Get all files recursively under a directory.""" |
| |
| target_ids = {dir_id} |
| changed = True |
| while changed: |
| changed = False |
| for d in tree['dirs'].values(): |
| if d['parent_id'] in target_ids and d['id'] not in target_ids: |
| target_ids.add(d['id']) |
| changed = True |
| return [f for f in tree['files'] if f['directory_id'] in target_ids] |
|
|
|
|
| |
| |
| |
|
|
|
|
| async def _get_accessible_kb_ids( |
| user: dict, model_knowledge: list[dict] | None, knowledge_id: str | None = None |
| ) -> list[tuple[str, str, str]]: |
| """Get list of (kb_id, kb_name, kb_description) the user can access.""" |
| from open_webui.models.access_grants import AccessGrants |
| from open_webui.models.groups import Groups |
| from open_webui.models.knowledge import Knowledges |
|
|
| user_id = user.get('id') |
| user_role = user.get('role', 'user') |
| user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)] |
|
|
| async def _has_access(kb): |
| return ( |
| user_role == 'admin' |
| or kb.user_id == user_id |
| or await AccessGrants.has_access( |
| user_id=user_id, |
| resource_type='knowledge', |
| resource_id=kb.id, |
| permission='read', |
| user_group_ids=set(user_group_ids), |
| ) |
| ) |
|
|
| result = [] |
|
|
| if model_knowledge: |
| attached_kb_ids = set() |
| for item in model_knowledge: |
| if item.get('type') == 'collection': |
| attached_kb_ids.add(item.get('id')) |
| if knowledge_id: |
| if knowledge_id not in attached_kb_ids: |
| return [] |
| attached_kb_ids = {knowledge_id} |
| for kb_id in attached_kb_ids: |
| kb = await Knowledges.get_knowledge_by_id(kb_id) |
| if kb and await _has_access(kb): |
| result.append((kb.id, kb.name, kb.description or '')) |
| elif knowledge_id: |
| kb = await Knowledges.get_knowledge_by_id(knowledge_id) |
| if kb and await _has_access(kb): |
| result.append((kb.id, kb.name, kb.description or '')) |
| else: |
| search = await Knowledges.search_knowledge_bases( |
| user_id, |
| filter={'query': '', 'user_id': user_id, 'group_ids': user_group_ids}, |
| skip=0, |
| limit=50, |
| ) |
| for kb in search.items: |
| result.append((kb.id, kb.name, kb.description or '')) |
|
|
| return result |
|
|
|
|
| async def _get_accessible_files( |
| user: dict, model_knowledge: list[dict] | None, knowledge_id: str | None = None |
| ) -> list[dict]: |
| """Get all files the user can access, with KB metadata and directory_id (no path computation).""" |
| from open_webui.models.files import Files |
| from open_webui.models.knowledge import Knowledges |
|
|
| kb_ids = await _get_accessible_kb_ids(user, model_knowledge, knowledge_id) |
| files = [] |
|
|
| for kb_id, kb_name, _ in kb_ids: |
| kb_files = await Knowledges.get_files_with_directory_ids(kb_id) |
| for file_model, dir_id in kb_files: |
| files.append( |
| { |
| 'id': file_model.id, |
| 'filename': file_model.filename, |
| 'directory_id': dir_id, |
| 'size': file_model.meta.get('size') if file_model.meta else None, |
| 'type': file_model.meta.get('content_type') if file_model.meta else None, |
| 'updated_at': file_model.updated_at, |
| 'knowledge_id': kb_id, |
| 'knowledge_name': kb_name, |
| } |
| ) |
|
|
| |
| if model_knowledge: |
| attached_file_ids = set() |
| for item in model_knowledge: |
| if item.get('type') == 'file': |
| attached_file_ids.add(item.get('id')) |
| for fid in attached_file_ids: |
| f = await Files.get_file_by_id(fid) |
| if f: |
| files.append( |
| { |
| 'id': f.id, |
| 'filename': f.filename, |
| 'directory_id': None, |
| 'size': f.meta.get('size') if f.meta else None, |
| 'type': f.meta.get('content_type') if f.meta else None, |
| 'updated_at': f.updated_at, |
| 'knowledge_id': None, |
| 'knowledge_name': None, |
| } |
| ) |
|
|
| return files |
|
|
|
|
| async def _resolve_dir_path(path: str, knowledge_id: str) -> str | None: |
| """Walk a directory path one level at a time. Returns dir_id or None.""" |
| from open_webui.models.knowledge import Knowledges |
|
|
| parts = path.strip('/').split('/') |
| current_parent = None |
|
|
| for part in parts: |
| dirs = await Knowledges.get_directories(knowledge_id, parent_id=current_parent) |
| match = next((d for d in dirs if d.name == part), None) |
| if not match: |
| return None |
| current_parent = match.id |
|
|
| return current_parent |
|
|
|
|
| async def _get_descendant_dir_ids(dir_id: str, knowledge_id: str) -> set[str]: |
| """Collect all descendant directory IDs recursively.""" |
| from open_webui.models.knowledge import Knowledges |
|
|
| result = {dir_id} |
| queue = [dir_id] |
| while queue: |
| parent = queue.pop() |
| children = await Knowledges.get_directories(knowledge_id, parent_id=parent) |
| for child in children: |
| if child.id not in result: |
| result.add(child.id) |
| queue.append(child.id) |
| return result |
|
|
|
|
| async def _resolve_file(ref: str, user: dict, model_knowledge: list[dict] | None) -> dict | None: |
| """Resolve a file reference (ID, path, or filename) to a file info dict with content.""" |
| from open_webui.models.files import Files |
|
|
| |
| accessible = await _get_accessible_files(user, model_knowledge) |
| accessible_ids = {fi['id'] for fi in accessible} |
|
|
| |
| f = await Files.get_file_by_id(ref) |
| if f and f.data: |
| if f.id not in accessible_ids: |
| return None |
| return { |
| 'id': f.id, |
| 'filename': f.filename, |
| 'content': f.data.get('content', ''), |
| 'meta': f.meta, |
| 'updated_at': f.updated_at, |
| 'created_at': f.created_at, |
| } |
|
|
| |
| ref_clean = ref.strip('/') |
| if '/' in ref_clean: |
| dir_path, filename = ref_clean.rsplit('/', 1) |
| |
| kb_ids = {fi['knowledge_id'] for fi in accessible if fi.get('knowledge_id')} |
| for kb_id in kb_ids: |
| dir_id = await _resolve_dir_path(dir_path, kb_id) |
| if dir_id is None: |
| continue |
| |
| matches = [fi for fi in accessible if fi['filename'] == filename and fi['directory_id'] == dir_id] |
| if len(matches) == 1: |
| f = await Files.get_file_by_id(matches[0]['id']) |
| if f and f.data: |
| return { |
| 'id': f.id, |
| 'filename': f.filename, |
| 'content': f.data.get('content', ''), |
| 'meta': f.meta, |
| 'updated_at': f.updated_at, |
| 'created_at': f.created_at, |
| 'knowledge_id': matches[0].get('knowledge_id'), |
| 'knowledge_name': matches[0].get('knowledge_name'), |
| } |
|
|
| |
| matches = [fi for fi in accessible if fi['filename'] == ref] |
|
|
| if len(matches) == 1: |
| f = await Files.get_file_by_id(matches[0]['id']) |
| if f and f.data: |
| return { |
| 'id': f.id, |
| 'filename': f.filename, |
| 'content': f.data.get('content', ''), |
| 'meta': f.meta, |
| 'updated_at': f.updated_at, |
| 'created_at': f.created_at, |
| 'knowledge_id': matches[0].get('knowledge_id'), |
| 'knowledge_name': matches[0].get('knowledge_name'), |
| } |
| elif len(matches) > 1: |
| return { |
| 'error': f'Ambiguous filename "{ref}". Use full path to disambiguate:\n' |
| + '\n'.join(f' {m["id"]} {m["filename"]} ({m.get("knowledge_name", "direct")})' for m in matches) |
| } |
|
|
| return None |
|
|
|
|
| async def _get_file_content(file_id: str) -> str | None: |
| """Get file content by ID.""" |
| from open_webui.models.files import Files |
|
|
| f = await Files.get_file_by_id(file_id) |
| if f and f.data: |
| return f.data.get('content', '') |
| return None |
|
|
|
|
| |
| |
| |
|
|
|
|
| async def _kb_ls(args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None) -> str: |
| """List files and directories. Supports: ls, ls <path>, ls -a (flat).""" |
| from open_webui.models.knowledge import Knowledges |
|
|
| flat_mode = 'a' in flags |
| path_arg = args[0] if args else None |
|
|
| kb_ids = await _get_accessible_kb_ids(user, model_knowledge, knowledge_id=None) |
|
|
| |
| target_kb_id = None |
| dir_path = None |
| if path_arg: |
| for kb_id, kb_name, _ in kb_ids: |
| if kb_id == path_arg: |
| target_kb_id = kb_id |
| break |
| if not target_kb_id: |
| dir_path = path_arg.strip('/') |
|
|
| if target_kb_id: |
| kb_ids = [(kid, kn, kd) for kid, kn, kd in kb_ids if kid == target_kb_id] |
|
|
| if not kb_ids: |
| return 'No knowledge bases found.' |
|
|
| lines = [] |
| for kb_id, kb_name, kb_desc in kb_ids: |
| header = f'Knowledge Base: {kb_name} ({kb_id})' |
| if kb_desc: |
| header += f'\n {kb_desc}' |
| lines.append(header) |
|
|
| if flat_mode: |
| |
| tree = await _build_directory_tree(kb_id) |
| for f in tree['files']: |
| lines.append(f' {f["id"]} {f["path"]} {_fmt_size(f)} {_fmt_date(f)}') |
| lines.append('') |
| continue |
|
|
| |
| target_dir_id = None |
| if dir_path: |
| target_dir_id = await _resolve_dir_path(dir_path, kb_id) |
| if target_dir_id is None: |
| lines.append(f' Directory not found: {dir_path}') |
| lines.append('') |
| continue |
| lines.append(f' Path: {dir_path}/') |
|
|
| |
| subdirs = await Knowledges.get_directories(kb_id, parent_id=target_dir_id) |
| for d in subdirs: |
| lines.append(f' π {d.name}/') |
|
|
| |
| accessible = await _get_accessible_files(user, model_knowledge, knowledge_id=kb_id) |
| dir_files = [f for f in accessible if f['directory_id'] == target_dir_id] |
| for f in dir_files: |
| lines.append(f' {f["id"]} {f["filename"]} {_fmt_size(f)} {_fmt_date(f)}') |
|
|
| if not subdirs and not dir_files: |
| lines.append(' (empty)') |
| lines.append('') |
|
|
| return '\n'.join(lines).rstrip() |
|
|
|
|
| def _fmt_size(f: dict) -> str: |
| return f'{f["size"]:,} bytes' if f.get('size') else '' |
|
|
|
|
| def _fmt_date(f: dict) -> str: |
| if f.get('updated_at'): |
| from datetime import datetime, timezone |
|
|
| dt = datetime.fromtimestamp(f['updated_at'], tz=timezone.utc) |
| return dt.strftime('%Y-%m-%d') |
| return '' |
|
|
|
|
| async def _kb_cat(args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None) -> str: |
| """Read file content. Use -n for line numbers.""" |
| if not args: |
| return 'Usage: cat [-n] <file_id or filename>' |
|
|
| resolved = await _resolve_file(args[0], user, model_knowledge) |
| if not resolved: |
| return f'File not found: {args[0]}' |
| if 'error' in resolved: |
| return resolved['error'] |
|
|
| content = resolved['content'] |
| show_numbers = 'n' in flags |
|
|
| if len(content) > MAX_CAT_CHARS: |
| content = content[:MAX_CAT_CHARS] |
| truncated = True |
| else: |
| truncated = False |
|
|
| if show_numbers: |
| lines = content.split('\n') |
| content = '\n'.join(f'{i}: {line}' for i, line in enumerate(lines, 1)) |
|
|
| if truncated: |
| content += f'\n[truncated at {MAX_CAT_CHARS:,} chars β use head/tail/sed/grep to navigate]' |
|
|
| return content |
|
|
|
|
| async def _kb_head( |
| args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None, piped_input: str | None = None |
| ) -> str: |
| """First N lines of a file or piped input.""" |
| n, args = _extract_numeric_flag(args) |
| if n is None: |
| n = DEFAULT_HEAD_LINES |
|
|
| if piped_input is not None: |
| lines = piped_input.split('\n') |
| return '\n'.join(lines[:n]) |
|
|
| if not args: |
| return 'Usage: head [-N] <file>' |
|
|
| resolved = await _resolve_file(args[0], user, model_knowledge) |
| if not resolved: |
| return f'File not found: {args[0]}' |
| if 'error' in resolved: |
| return resolved['error'] |
|
|
| lines = resolved['content'].split('\n') |
| total = len(lines) |
| result = '\n'.join(lines[:n]) |
| if total > n: |
| result += f'\n[showing {n} of {total} lines]' |
| return result |
|
|
|
|
| async def _kb_tail( |
| args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None, piped_input: str | None = None |
| ) -> str: |
| """Last N lines of a file or piped input.""" |
| n, args = _extract_numeric_flag(args) |
| if n is None: |
| n = DEFAULT_TAIL_LINES |
|
|
| if piped_input is not None: |
| lines = piped_input.split('\n') |
| return '\n'.join(lines[-n:]) |
|
|
| if not args: |
| return 'Usage: tail [-N] <file>' |
|
|
| resolved = await _resolve_file(args[0], user, model_knowledge) |
| if not resolved: |
| return f'File not found: {args[0]}' |
| if 'error' in resolved: |
| return resolved['error'] |
|
|
| lines = resolved['content'].split('\n') |
| total = len(lines) |
| result = '\n'.join(lines[-n:]) |
| if total > n: |
| result += f'\n[showing last {n} of {total} lines]' |
| return result |
|
|
|
|
| async def _kb_grep( |
| args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None, piped_input: str | None = None |
| ) -> str: |
| """Text search across files or piped input. Supports -E for regex.""" |
| if not args: |
| return 'Usage: grep [-E] [-i] [-l] [-c] "pattern" [file] [*.ext]' |
|
|
| pattern = args[0] |
| file_ref = None |
| ext_filter = None |
| dir_scope = None |
|
|
| for arg in args[1:]: |
| if '*' in arg or arg.startswith('.'): |
| ext_filter = arg.lstrip('*').lstrip('.') |
| elif arg.endswith('/'): |
| dir_scope = arg.strip('/') |
| else: |
| file_ref = arg |
|
|
| case_insensitive = 'i' in flags |
| filenames_only = 'l' in flags |
| count_only = 'c' in flags |
| use_regex = 'E' in flags |
|
|
| _matches, err = build_matcher(pattern, case_insensitive, use_regex) |
| if err: |
| return err |
|
|
| |
| if piped_input is not None: |
| lines = piped_input.split('\\n') |
| matched = [] |
| for i, line in enumerate(lines, 1): |
| if _matches(line): |
| matched.append(f'{i}: {line}') |
| return '\\n'.join(matched) if matched else f'No matches for "{pattern}"' |
|
|
| |
| if file_ref and not dir_scope: |
| resolved = await _resolve_file(file_ref, user, model_knowledge) |
| if not resolved: |
| |
| dir_scope = file_ref |
| elif 'error' in resolved: |
| return resolved['error'] |
| else: |
| lines = resolved['content'].split('\\n') |
| matched = [] |
| for i, line in enumerate(lines, 1): |
| if _matches(line): |
| matched.append(f'{i}: {line}') |
|
|
| if count_only: |
| return f'{resolved["id"]} {resolved["filename"]}: {len(matched)}' |
| if filenames_only: |
| return f'{resolved["id"]} {resolved["filename"]}' if matched else f'No matches for "{pattern}"' |
|
|
| if not matched: |
| return f'No matches for "{pattern}" in {resolved["filename"]}' |
| return '\\n'.join(matched) |
|
|
| |
| accessible = await _get_accessible_files(user, model_knowledge) |
|
|
| if dir_scope: |
| |
| kb_ids = {fi['knowledge_id'] for fi in accessible if fi.get('knowledge_id')} |
| target_dir_ids = set() |
| for kb_id in kb_ids: |
| dir_id = await _resolve_dir_path(dir_scope, kb_id) |
| if dir_id: |
| desc = await _get_descendant_dir_ids(dir_id, kb_id) |
| target_dir_ids.update(desc) |
| if not target_dir_ids: |
| return f'No files found under "{dir_scope}/"' |
| accessible = [f for f in accessible if f.get('directory_id') in target_dir_ids] |
| if not accessible: |
| return f'No files found under "{dir_scope}/"' |
|
|
| if ext_filter: |
| accessible = [f for f in accessible if f['filename'].endswith(f'.{ext_filter}')] |
|
|
| if len(accessible) > MAX_GREP_FILES: |
| return f'Too many files ({len(accessible)}). Scope your search: grep "{pattern}" docs/ or grep "{pattern}" *.py' |
|
|
| from open_webui.models.files import Files |
|
|
| results = [] |
| file_match_counts = [] |
| files_with_matches = [] |
| total_matches = 0 |
|
|
| for file_info in accessible: |
| f = await Files.get_file_by_id(file_info['id']) |
| if not f or not f.data: |
| continue |
|
|
| content = f.data.get('content', '') |
| if not content: |
| continue |
|
|
| lines = content.split('\n') |
| file_matches = [] |
| for i, line in enumerate(lines, 1): |
| if _matches(line): |
| file_matches.append((i, line)) |
|
|
| if file_matches: |
| files_with_matches.append(file_info) |
| file_match_counts.append((file_info, len(file_matches))) |
| total_matches += len(file_matches) |
|
|
| if not count_only and not filenames_only: |
| for line_num, line_text in file_matches: |
| if len(results) < MAX_GREP_MATCHES: |
| results.append(f'{file_info["id"]} {file_info["filename"]}:{line_num}: {line_text.rstrip()}') |
|
|
| if count_only: |
| if not file_match_counts: |
| return f'No matches for "{pattern}"' |
| lines = [f'{fi["id"]} {fi["filename"]}: {cnt}' for fi, cnt in file_match_counts] |
| lines.append(f'Total: {total_matches} matches in {len(file_match_counts)} files') |
| return '\n'.join(lines) |
|
|
| if filenames_only: |
| if not files_with_matches: |
| return f'No matches for "{pattern}"' |
| return '\n'.join(f'{fi["id"]} {fi["filename"]}' for fi in files_with_matches) |
|
|
| if not results: |
| return f'No matches for "{pattern}" across {len(accessible)} files' |
|
|
| output = '\n'.join(results) |
| if total_matches > MAX_GREP_MATCHES: |
| output += f'\n[showing {MAX_GREP_MATCHES} of {total_matches} matches]' |
| return output |
|
|
|
|
| async def _kb_find(args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None) -> str: |
| """Find files by name/glob pattern, optionally scoped to a directory.""" |
| if not args: |
| return 'Usage: find "*.md" or find docs/ "*.md"' |
|
|
| import fnmatch |
|
|
| |
| dir_scope = None |
| if len(args) >= 2 and ('/' in args[0] or not ('*' in args[0] or '?' in args[0])): |
| dir_scope = args[0].strip('/') |
| pattern = args[1] |
| else: |
| pattern = args[0] |
|
|
| accessible = await _get_accessible_files(user, model_knowledge) |
|
|
| if dir_scope: |
| kb_ids = {fi['knowledge_id'] for fi in accessible if fi.get('knowledge_id')} |
| target_dir_ids = set() |
| for kb_id in kb_ids: |
| dir_id = await _resolve_dir_path(dir_scope, kb_id) |
| if dir_id: |
| desc = await _get_descendant_dir_ids(dir_id, kb_id) |
| target_dir_ids.update(desc) |
| accessible = [f for f in accessible if f.get('directory_id') in target_dir_ids] |
|
|
| matched = [f for f in accessible if fnmatch.fnmatch(f['filename'], pattern)] |
|
|
| if not matched: |
| scope_str = f' under "{dir_scope}/"' if dir_scope else '' |
| return f'No files matching "{pattern}"{scope_str}' |
|
|
| lines = [] |
| for f in matched: |
| kb_info = f' ({f["knowledge_name"]})' if f.get('knowledge_name') else '' |
| lines.append(f'{f["id"]} {f["filename"]}{kb_info}') |
| return '\n'.join(lines) |
|
|
|
|
| async def _kb_wc( |
| args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None, piped_input: str | None = None |
| ) -> str: |
| """Word, line, character counts.""" |
| if piped_input is not None: |
| lines = piped_input.count('\n') + (1 if piped_input and not piped_input.endswith('\n') else 0) |
| words = len(piped_input.split()) |
| chars = len(piped_input) |
| if 'l' in flags: |
| return str(lines) |
| return f' {lines} {words} {chars}' |
|
|
| if not args: |
| return 'Usage: wc [-l] <file>' |
|
|
| resolved = await _resolve_file(args[0], user, model_knowledge) |
| if not resolved: |
| return f'File not found: {args[0]}' |
| if 'error' in resolved: |
| return resolved['error'] |
|
|
| content = resolved['content'] |
| lines = content.count('\n') + (1 if content and not content.endswith('\n') else 0) |
| words = len(content.split()) |
| chars = len(content) |
|
|
| if 'l' in flags: |
| return f' {lines} {resolved["filename"]}' |
| return f' {lines} {words} {chars} {resolved["filename"]}' |
|
|
|
|
| async def _kb_stat(args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None) -> str: |
| """File metadata.""" |
| if not args: |
| return 'Usage: stat <file>' |
|
|
| resolved = await _resolve_file(args[0], user, model_knowledge) |
| if not resolved: |
| return f'File not found: {args[0]}' |
| if 'error' in resolved: |
| return resolved['error'] |
|
|
| content = resolved['content'] |
| lines = content.count('\n') + (1 if content and not content.endswith('\n') else 0) |
| words = len(content.split()) |
| chars = len(content) |
|
|
| meta = resolved.get('meta') or {} |
| size = meta.get('size', chars) |
| content_type = meta.get('content_type', 'unknown') |
|
|
| out = [ |
| f' File: {resolved["filename"]}', |
| f' ID: {resolved["id"]}', |
| f' Size: {size:,} bytes', |
| f' Type: {content_type}', |
| f' Lines: {lines:,}', |
| f' Words: {words:,}', |
| f' Chars: {chars:,}', |
| ] |
|
|
| if resolved.get('created_at'): |
| from datetime import datetime, timezone |
|
|
| dt = datetime.fromtimestamp(resolved['created_at'], tz=timezone.utc) |
| out.append(f' Created: {dt.strftime("%Y-%m-%d %H:%M:%S UTC")}') |
| if resolved.get('updated_at'): |
| from datetime import datetime, timezone |
|
|
| dt = datetime.fromtimestamp(resolved['updated_at'], tz=timezone.utc) |
| out.append(f' Updated: {dt.strftime("%Y-%m-%d %H:%M:%S UTC")}') |
| if resolved.get('knowledge_name'): |
| out.append(f' KB: {resolved["knowledge_name"]} ({resolved.get("knowledge_id", "")})') |
|
|
| return '\n'.join(out) |
|
|
|
|
| async def _kb_sed( |
| args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None, piped_input: str | None = None |
| ) -> str: |
| """Extract line range from a file. Usage: sed -n 'M,Np' <file>""" |
| if piped_input is not None: |
| |
| start, end = 1, None |
| if 'n' in flags and args: |
| m = re.match(r'^(\d+),(\d+)p?$', args[0]) |
| if m: |
| start, end = int(m.group(1)), int(m.group(2)) |
| args = args[1:] |
| lines = piped_input.split('\n') |
| selected = lines[max(0, start - 1) : (end or len(lines))] |
| return '\n'.join(selected) |
|
|
| |
| range_str = None |
| file_ref = None |
|
|
| for arg in args: |
| m = re.match(r"^'?(\d+),(\d+)p?'?$", arg) |
| if m: |
| range_str = arg |
| else: |
| file_ref = arg |
|
|
| if not range_str or not file_ref: |
| return "Usage: sed -n '40,60p' <file>" |
|
|
| m = re.match(r"^'?(\d+),(\d+)p?'?$", range_str) |
| start, end = int(m.group(1)), int(m.group(2)) |
|
|
| if start > end: |
| return f'Invalid range: start ({start}) > end ({end})' |
|
|
| resolved = await _resolve_file(file_ref, user, model_knowledge) |
| if not resolved: |
| return f'File not found: {file_ref}' |
| if 'error' in resolved: |
| return resolved['error'] |
|
|
| lines = resolved['content'].split('\n') |
| total = len(lines) |
| selected = lines[max(0, start - 1) : end] |
| result = '\n'.join(selected) |
| result += f'\n[lines {start}-{min(end, total)} of {total}]' |
| return result |
|
|
|
|
| |
| |
| |
|
|
|
|
| async def _kb_tree(args: list[str], flags: set[str], user: dict, model_knowledge: list[dict] | None) -> str: |
| """Show directory tree structure.""" |
| kb_ids = await _get_accessible_kb_ids(user, model_knowledge) |
| if not kb_ids: |
| return 'No knowledge bases found.' |
|
|
| dir_scope = args[0].strip('/') if args else None |
| output = [] |
|
|
| for kb_id, kb_name, kb_desc in kb_ids: |
| tree = await _build_directory_tree(kb_id) |
| header = f'Knowledge Base: {kb_name} ({kb_id})' |
| if kb_desc: |
| header += f'\n {kb_desc}' |
| output.append(header) |
|
|
| |
| root_dir_id = None |
| if dir_scope: |
| root_dir_id = _resolve_path(dir_scope, tree) |
| if root_dir_id is None: |
| output.append(f' Directory not found: {dir_scope}') |
| output.append('') |
| continue |
| output.append(f' {dir_scope}/') |
|
|
| def _render_tree(parent_id, prefix=' '): |
| items = [] |
| subdirs = _get_subdirs(tree, parent_id) |
| files = _get_files_in_dir(tree, parent_id) |
| entries = [('dir', d) for d in subdirs] + [('file', f) for f in files] |
|
|
| for idx, (etype, entry) in enumerate(entries): |
| is_last = idx == len(entries) - 1 |
| connector = 'βββ ' if is_last else 'βββ ' |
| child_prefix = prefix + (' ' if is_last else 'β ') |
|
|
| if etype == 'dir': |
| items.append(f'{prefix}{connector}π {entry["name"]}/') |
| items.extend(_render_tree(entry['id'], child_prefix)) |
| else: |
| items.append(f'{prefix}{connector}{entry["filename"]}') |
| return items |
|
|
| output.extend(_render_tree(root_dir_id)) |
|
|
| |
| total_dirs = len(tree['dirs']) |
| total_files = len(tree['files']) |
| output.append(f'\n {total_dirs} directories, {total_files} files') |
| output.append('') |
|
|
| return '\n'.join(output).rstrip() |
|
|
|
|
| COMMAND_MAP = { |
| 'ls': _kb_ls, |
| 'cat': _kb_cat, |
| 'head': _kb_head, |
| 'tail': _kb_tail, |
| 'grep': _kb_grep, |
| 'find': _kb_find, |
| 'wc': _kb_wc, |
| 'stat': _kb_stat, |
| 'sed': _kb_sed, |
| 'tree': _kb_tree, |
| } |
|
|
|
|
| async def _execute_pipeline( |
| segments: list[list[str]], |
| user: dict, |
| model_knowledge: list[dict] | None, |
| ) -> str: |
| """Execute a pipeline of commands, passing text between them.""" |
| piped_input = None |
|
|
| for tokens in segments: |
| cmd_name = tokens[0].lower() |
| rest = tokens[1:] |
|
|
| handler = COMMAND_MAP.get(cmd_name) |
| if not handler: |
| return f'Unknown command: {cmd_name}. Available: {", ".join(sorted(COMMAND_MAP.keys()))}' |
|
|
| flags, args = _extract_flags(rest) |
|
|
| |
| if piped_input is not None and cmd_name in ('head', 'tail', 'grep', 'wc', 'sed'): |
| piped_input = await handler(args, flags, user, model_knowledge, piped_input=piped_input) |
| else: |
| piped_input = await handler(args, flags, user, model_knowledge) |
|
|
| return piped_input or '' |
|
|
|
|
| |
| |
| |
|
|
|
|
| async def kb_exec( |
| command: str, |
| __request__: Request = None, |
| __user__: dict = None, |
| __model_knowledge__: Optional[list[dict]] = None, |
| ) -> str: |
| """ |
| Run a filesystem command against the knowledge base. |
| |
| Commands: |
| ls β list root files and directories |
| ls docs/ β list contents of a directory |
| ls -a β flat list of all files with full paths |
| tree β recursive directory tree view |
| tree docs/ β subtree from a directory |
| cat -n <file> β read file with line numbers |
| head -20 <file> β first 20 lines |
| tail -10 <file> β last 10 lines |
| sed -n '40,60p' <file> β view lines 40-60 |
| grep "text" <file> β exact text search (auto-detects regex) |
| grep -i "text" β case-insensitive |
| grep -l "text" β filenames-only |
| grep -c "text" β match counts |
| grep "text" docs/ β search within a directory |
| grep "text" *.py β filter by extension |
| find "*.md" β find files by glob |
| find docs/ "*.md" β find within a directory |
| wc <file> β line/word/char counts |
| stat <file> β file metadata |
| |
| Pipes: grep "auth" | head -5 |
| Files: reference by path (docs/api/auth.md), filename, or file ID |
| |
| :param command: A filesystem command string |
| :return: Command output as text |
| """ |
| if not __user__: |
| return 'Error: User context not available' |
|
|
| if not command or not command.strip(): |
| return 'Usage: kb_exec("<command>"). Run kb_exec("ls") to start.' |
|
|
| try: |
| segments = _parse_pipeline(command.strip()) |
| if not segments: |
| return 'Could not parse command. Run kb_exec("ls") to start.' |
|
|
| return await _execute_pipeline(segments, __user__, __model_knowledge__) |
| except Exception as e: |
| log.exception(f'kb_exec error: {e}') |
| return f'Error: {e}' |
|
|