feat: implement obsidian-kb skill with long video chunking

- Multi-source content aggregation (Bilibili/YouTube/Zhihu)
- Smart inbox management with AI quality scoring
- Long video chunking analysis (30min+ auto-segmentation)
- Topic-based organization
- Git auto-sync to Obsidian
- Template system with Jinja2 and fallback rendering
- CLI commands: subscribe, inbox, process, skip, status, search
- Comprehensive test suite with unit and integration tests
- Documentation: README.md and SKILL.md

Checkpoint 7-8 completed by Agent E
This commit is contained in:
OpenClaw Agent
2026-02-06 18:15:30 +08:00
commit f26d4d8f7f
37 changed files with 6414 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
"""
Configuration management for Obsidian KB Skill.
"""
import os
import yaml
from pathlib import Path
from typing import Optional, Dict, Any
# Global config instance
_config_instance: Optional['Config'] = None
def get_config(config_path: Optional[str] = None) -> 'Config':
"""Get global config instance."""
global _config_instance
if _config_instance is None or config_path:
_config_instance = Config(config_path)
return _config_instance
class Config:
"""Configuration manager."""
DEFAULT_CONFIG = {
'ai': {
'provider': 'openai',
'model': 'gpt-4',
'api_key': None,
},
'storage': {
'inbox_dir': 'inbox',
'topics_dir': 'topics',
'data_dir': 'data',
},
'git': {
'enabled': False,
'vault_path': '~/Documents/Obsidian/KB',
'remote': 'origin',
'branch': 'main',
'auto_push': False,
'commit_message_template': 'Add note: {title}',
},
'obsidian': {
'vault_path': '~/Documents/Obsidian/KB',
'notes_folder': 'Notes',
},
'chunking': {
'enabled': True,
'chunk_duration_seconds': 900, # 15 minutes
'max_chunk_duration_seconds': 1800, # 30 minutes
'long_video_threshold': 1800, # 30 minutes
},
'sources': {
'bilibili': {
'enabled': True,
'cookie': None,
},
'youtube': {
'enabled': False,
},
'zhihu': {
'enabled': False,
},
},
}
def __init__(self, config_path: Optional[str] = None, load_env: bool = True):
"""
Initialize configuration.
Args:
config_path: Path to config file. If None, uses default locations.
load_env: Whether to load environment variable overrides.
"""
self.config_path = config_path or self._find_config_file()
self._config = self._load_config(load_env)
def _find_config_file(self) -> str:
"""Find configuration file in standard locations."""
search_paths = [
Path.cwd() / 'config.yaml',
Path.cwd() / 'config.yml',
Path.home() / '.obsidian-kb' / 'config.yaml',
Path.home() / '.config' / 'obsidian-kb' / 'config.yaml',
]
for path in search_paths:
if path.exists():
return str(path)
# Return default path
return str(Path.cwd() / 'config.yaml')
def _load_config(self, load_env: bool = True) -> Dict[str, Any]:
"""Load configuration from file or use defaults."""
config = self.DEFAULT_CONFIG.copy()
if Path(self.config_path).exists():
with open(self.config_path, 'r', encoding='utf-8') as f:
user_config = yaml.safe_load(f)
if user_config:
self._deep_update(config, user_config)
# Override with environment variables
if load_env:
self._apply_env_overrides(config)
return config
def _deep_update(self, base: Dict, update: Dict) -> None:
"""Deep update dictionary."""
for key, value in update.items():
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
self._deep_update(base[key], value)
else:
base[key] = value
def _apply_env_overrides(self, config: Dict[str, Any]) -> None:
"""Apply environment variable overrides."""
# AI settings
if os.getenv('OPENAI_API_KEY'):
config['ai']['api_key'] = os.getenv('OPENAI_API_KEY')
if os.getenv('AI_MODEL'):
config['ai']['model'] = os.getenv('AI_MODEL')
# Git settings
if os.getenv('OBSIDIAN_VAULT_PATH'):
config['git']['vault_path'] = os.getenv('OBSIDIAN_VAULT_PATH')
# Bilibili cookie
if os.getenv('BILIBILI_COOKIE'):
config['sources']['bilibili']['cookie'] = os.getenv('BILIBILI_COOKIE')
def get(self, key: str, default: Any = None) -> Any:
"""
Get configuration value using dot notation.
Args:
key: Key in dot notation (e.g., 'ai.model')
default: Default value if key not found
Returns:
Configuration value
"""
keys = key.split('.')
value = self._config
for k in keys:
if isinstance(value, dict) and k in value:
value = value[k]
else:
return default
return value
def set(self, key: str, value: Any) -> None:
"""
Set configuration value using dot notation.
Args:
key: Key in dot notation
value: Value to set
"""
keys = key.split('.')
config = self._config
for k in keys[:-1]:
if k not in config:
config[k] = {}
config = config[k]
config[keys[-1]] = value
def save(self) -> None:
"""Save configuration to file."""
Path(self.config_path).parent.mkdir(parents=True, exist_ok=True)
with open(self.config_path, 'w', encoding='utf-8') as f:
yaml.dump(self._config, f, default_flow_style=False, allow_unicode=True)
@property
def obsidian_path(self) -> str:
"""Get obsidian path (alias for vault_path)."""
return str(self.vault_path)
@property
def vault_path(self) -> Path:
"""Get Obsidian vault path."""
path = self.get('obsidian.vault_path') or self.get('git.vault_path', '~/Documents/Obsidian/KB')
return Path(path).expanduser()
@property
def notes_folder(self) -> str:
"""Get notes folder."""
return self.get('obsidian.notes_folder', 'Notes')
@property
def ai_api_key(self) -> Optional[str]:
"""Get AI API key."""
return self.get('ai.api_key')
@property
def ai_model(self) -> str:
"""Get AI model."""
return self.get('ai.model', 'gpt-4')
@property
def chunking_enabled(self) -> bool:
"""Check if chunking is enabled."""
return self.get('chunking.enabled', True)
@property
def chunk_duration(self) -> int:
"""Get chunk duration in seconds."""
return self.get('chunking.chunk_duration_seconds', 900)
@property
def long_video_threshold(self) -> int:
"""Get long video threshold in seconds."""
return self.get('chunking.long_video_threshold', 1800)
@property
def quality_threshold(self) -> float:
"""Get quality threshold."""
return self.get('quality_threshold', 0.7)
@property
def obsidian(self) -> 'Config':
"""Get obsidian config section (returns self for compatibility)."""
return self
@property
def git(self) -> 'Config':
"""Get git config section (returns self for compatibility)."""
return self
@property
def enabled(self) -> bool:
"""Check if git is enabled."""
return self.get('git.enabled', False)
@property
def auto_push(self) -> bool:
"""Check if auto push is enabled."""
return self.get('git.auto_push', False)
@property
def commit_message_template(self) -> str:
"""Get commit message template."""
return self.get('git.commit_message_template', 'Add note: {title}')
@property
def kimi_api_key(self) -> str:
"""Get Kimi API key (alias for ai.api_key)."""
return self.get('ai.api_key') or ''
def ensure_dirs(self) -> None:
"""Ensure required directories exist."""
self.vault_path.mkdir(parents=True, exist_ok=True)
(self.vault_path / self.get('storage.inbox_dir', 'Inbox')).mkdir(exist_ok=True)
(self.vault_path / self.get('storage.topics_dir', 'Topics')).mkdir(exist_ok=True)
def validate(self) -> tuple[bool, list[str]]:
"""Validate configuration."""
errors = []
if not self.ai_api_key:
errors.append("AI API key not configured")
return len(errors) == 0, errors
# Subscriptions support
def get_subscriptions(self) -> list:
"""Get subscriptions list."""
return self.get('subscriptions', [])
def add_subscription(self, subscription: Dict[str, Any]) -> None:
"""Add a new subscription."""
subs = self.get('subscriptions', [])
subs.append(subscription)
self.set('subscriptions', subs)
self.save()