diff --git a/.gitignore b/.gitignore index f36cdf3..79e9b0b 100644 --- a/.gitignore +++ b/.gitignore @@ -177,4 +177,5 @@ tests/integration/test_playground/*.txt # mem cache .mem -.mcp.json \ No newline at end of file +.mcp.json +.memignore \ No newline at end of file diff --git a/memov/constants/__init__.py b/memov/constants/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/memov/constants/__init__.py @@ -0,0 +1 @@ + diff --git a/memov/constants/prompts.py b/memov/constants/prompts.py new file mode 100644 index 0000000..2da6a80 --- /dev/null +++ b/memov/constants/prompts.py @@ -0,0 +1,69 @@ +AI_SEARCH_SYSTEM_PROMPT = """You are an AI assistant helping users search their code history. +You will be given a list of commits with their prompts/messages. +Answer the user's question based ONLY on this history. Be concise. + +LANGUAGE: +- Respond in ENGLISH only. + +OUTPUT FORMAT (STRICT JSON ONLY): +- Return ONLY a JSON object (no markdown, no code fences, no extra text). +- The JSON object MUST contain exactly these keys: + - "answer": a concise string answer in English + - "commit_ids": an array of 7-character commit hashes (strings) that are relevant +- Use only commit hashes that appear in the provided history. +- If the history does NOT contain relevant information, set "commit_ids" to an empty array and answer with a short "not found" message. + +Example: +{"answer":"You fixed the login bug in commit abc1234","commit_ids":["abc1234"]}""" + +AI_SEARCH_USER_PROMPT_TEMPLATE = """Commit history (format: [hash] branch | prompt): + +{history_context} + +Question: {query} + +Return ONLY the JSON object with "answer" and "commit_ids" as specified.""" + +CLUSTER_SYSTEM_PROMPT = """You are a code assistant summarizing commit history. +You will be given a list of commits with prompts and metadata. + +LANGUAGE: +- Respond in ENGLISH only. + +OUTPUT FORMAT (STRICT JSON ONLY): +- Return ONLY a JSON object (no markdown, no extra text). +- The JSON must have a top-level key "features". +- "features" is an array of objects with: + - "name": short feature name (string) + - "summary": concise feature summary (string) + - "commit_ids": array of 7-char commit hashes (strings) +- Use only commit hashes that appear in the provided history. +""" + +CLUSTER_USER_PROMPT_TEMPLATE = """Task: Cluster the commits into distinct product features. + +Commit history (format: [hash] branch | op | prompt | files): +{history_context} + +Return JSON only with the required schema.""" + +SKILL_SYSTEM_PROMPT = """You are a code assistant creating a short skills document for a feature. +You will be given a feature name, summary, and related commits. + +LANGUAGE: +- Respond in ENGLISH only. + +OUTPUT FORMAT (STRICT JSON ONLY): +- Return ONLY a JSON object with: + - "title": short title (string) + - "content": concise skills summary in 3-6 sentences (string) + - "label": 1-2 word tag (string) +- Do not include markdown or extra fields. +""" + +SKILL_USER_PROMPT_TEMPLATE = """Feature: {feature_name} +Summary: {feature_summary} +Commits: +{commits_text} + +Return JSON only with the required schema.""" diff --git a/memov/core/git.py b/memov/core/git.py index cee6f98..8872d84 100644 --- a/memov/core/git.py +++ b/memov/core/git.py @@ -27,9 +27,8 @@ def subprocess_call( # Only set encoding when text mode is True if text: kwargs["encoding"] = "utf-8" - # Windows: handle potential encoding errors from git output - if sys.platform == "win32": - kwargs["errors"] = "replace" + # Be resilient to non-UTF-8 diffs/binary output across platforms + kwargs["errors"] = "replace" if input is not None: kwargs["input"] = input diff --git a/memov/storage/skills_db.py b/memov/storage/skills_db.py new file mode 100644 index 0000000..64535f2 --- /dev/null +++ b/memov/storage/skills_db.py @@ -0,0 +1,184 @@ +"""SQLite storage for AI-generated feature clusters and skills summaries.""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Iterable, Optional + + +@dataclass +class SkillFeature: + feature_id: int + name: str + summary: str + created_at: str + updated_at: str + commits: list[dict] + skill_title: Optional[str] + skill_content: Optional[str] + skill_label: Optional[str] + + +class SkillsDB: + """Lightweight SQLite helper for skills/feature clustering data.""" + + def __init__(self, db_path: Path): + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._ensure_schema() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(str(self.db_path)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + def _ensure_schema(self) -> None: + with self._connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS features ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + summary TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS feature_commits ( + feature_id INTEGER NOT NULL, + commit_hash TEXT NOT NULL, + commit_short TEXT NOT NULL, + PRIMARY KEY (feature_id, commit_hash), + FOREIGN KEY(feature_id) REFERENCES features(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS skills ( + feature_id INTEGER PRIMARY KEY, + title TEXT, + content TEXT, + label TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(feature_id) REFERENCES features(id) ON DELETE CASCADE + ); + """ + ) + # Backfill schema if label column is missing + cols = [row["name"] for row in conn.execute("PRAGMA table_info(skills)")] + if "label" not in cols: + conn.execute("ALTER TABLE skills ADD COLUMN label TEXT") + + def reset(self) -> None: + with self._connect() as conn: + conn.executescript( + """ + DELETE FROM skills; + DELETE FROM feature_commits; + DELETE FROM features; + """ + ) + + def insert_feature(self, name: str, summary: str) -> int: + now = datetime.utcnow().isoformat() + with self._connect() as conn: + cur = conn.execute( + """ + INSERT INTO features (name, summary, created_at, updated_at) + VALUES (?, ?, ?, ?) + """, + (name, summary, now, now), + ) + return int(cur.lastrowid) + + def set_feature_commits(self, feature_id: int, commits: Iterable[dict]) -> None: + with self._connect() as conn: + conn.execute("DELETE FROM feature_commits WHERE feature_id = ?", (feature_id,)) + conn.executemany( + """ + INSERT INTO feature_commits (feature_id, commit_hash, commit_short) + VALUES (?, ?, ?) + """, + [(feature_id, c["commit_hash"], c["commit_short"]) for c in commits], + ) + + def set_skill_doc(self, feature_id: int, title: str, content: str, label: str) -> None: + now = datetime.utcnow().isoformat() + with self._connect() as conn: + conn.execute( + """ + INSERT INTO skills (feature_id, title, content, label, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(feature_id) DO UPDATE SET + title = excluded.title, + content = excluded.content, + label = excluded.label, + updated_at = excluded.updated_at + """, + (feature_id, title, content, label, now, now), + ) + + def get_features(self) -> list[SkillFeature]: + with self._connect() as conn: + features_rows = conn.execute( + """ + SELECT id, name, summary, created_at, updated_at + FROM features + ORDER BY id ASC + """ + ).fetchall() + + commits_rows = conn.execute( + """ + SELECT feature_id, commit_hash, commit_short + FROM feature_commits + ORDER BY commit_short ASC + """ + ).fetchall() + + skills_rows = conn.execute( + """ + SELECT feature_id, title, content, label + FROM skills + """ + ).fetchall() + + commits_by_feature: dict[int, list[dict]] = {} + for row in commits_rows: + commits_by_feature.setdefault(int(row["feature_id"]), []).append( + { + "commit_hash": row["commit_hash"], + "commit_short": row["commit_short"], + } + ) + + skills_by_feature = { + int(row["feature_id"]): { + "title": row["title"], + "content": row["content"], + "label": row["label"], + } + for row in skills_rows + } + + features: list[SkillFeature] = [] + for row in features_rows: + feature_id = int(row["id"]) + skill = skills_by_feature.get(feature_id, {}) + features.append( + SkillFeature( + feature_id=feature_id, + name=row["name"], + summary=row["summary"], + created_at=row["created_at"], + updated_at=row["updated_at"], + commits=commits_by_feature.get(feature_id, []), + skill_title=skill.get("title"), + skill_content=skill.get("content"), + skill_label=skill.get("label"), + ) + ) + return features diff --git a/memov/web/server.py b/memov/web/server.py index 0c88444..ba359ba 100644 --- a/memov/web/server.py +++ b/memov/web/server.py @@ -13,7 +13,16 @@ from fastapi.staticfiles import StaticFiles from pydantic import BaseModel +from memov.constants.prompts import ( + AI_SEARCH_SYSTEM_PROMPT, + AI_SEARCH_USER_PROMPT_TEMPLATE, + CLUSTER_SYSTEM_PROMPT, + CLUSTER_USER_PROMPT_TEMPLATE, + SKILL_SYSTEM_PROMPT, + SKILL_USER_PROMPT_TEMPLATE, +) from memov.core.manager import MemovManager, MemStatus +from memov.storage.skills_db import SkillsDB LOGGER = logging.getLogger(__name__) @@ -28,6 +37,11 @@ class AISearchRequest(BaseModel): provider: str = "openai" # "anthropic" or "openai" +class SkillsRefreshRequest(BaseModel): + api_key: str + force: bool = False + + async def _call_anthropic(api_key: str, system_prompt: str, user_prompt: str) -> str: """Call Anthropic Claude API.""" async with httpx.AsyncClient(timeout=60.0) as client: @@ -50,33 +64,232 @@ async def _call_anthropic(api_key: str, system_prompt: str, user_prompt: str) -> return data["content"][0]["text"] -async def _call_openai(api_key: str, system_prompt: str, user_prompt: str) -> str: - """Call OpenAI API.""" +def _extract_openai_content(data: dict) -> str: + try: + # Responses API + if isinstance(data.get("output"), list): + parts = [] + for item in data["output"]: + if not isinstance(item, dict): + continue + if item.get("type") == "output_text" and isinstance(item.get("text"), str): + parts.append(item["text"]) + content = item.get("content") or [] + if isinstance(content, list): + for part in content: + if not isinstance(part, dict): + continue + if isinstance(part.get("text"), str): + parts.append(part["text"]) + return "".join(parts) + + # Chat Completions API + choices = data.get("choices") or [] + if not choices: + return "" + message = choices[0].get("message") or {} + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict) and isinstance(part.get("text"), str): + parts.append(part["text"]) + return "".join(parts) + except Exception: + return "" + return "" + + +async def _call_openai( + api_key: str, + system_prompt: str, + user_prompt: str, + force_json: bool = True, + max_output_tokens: int = 10240, +) -> str: + """Call OpenAI API (Responses API for GPT-5).""" async with httpx.AsyncClient(timeout=60.0) as client: + payload = { + "model": "gpt-5-nano", + "input": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "max_output_tokens": max_output_tokens, + } + if force_json: + payload["text"] = {"format": {"type": "json_object"}, "verbosity": "low"} + else: + payload["text"] = {"verbosity": "low"} + response = await client.post( - "https://api.openai.com/v1/chat/completions", + "https://api.openai.com/v1/responses", headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, - json={ - "model": "gpt-4o-mini", - "max_tokens": 1024, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - }, + json=payload, ) response.raise_for_status() data = response.json() - return data["choices"][0]["message"]["content"] + content = _extract_openai_content(data) + if not content: + try: + import json as _json + + raw_preview = _json.dumps(data)[:2000] + except Exception: + raw_preview = str(data)[:2000] + LOGGER.warning( + f"OpenAI empty content (force_json={force_json}). raw_preview={raw_preview!r}" + ) + return content + + +def _get_skills_db(manager: MemovManager) -> SkillsDB: + db_path = Path(manager.mem_root_path) / "skills.db" + return SkillsDB(db_path) + + +def _ensure_mem_logger(project_path: str) -> None: + """Ensure logs are also written to .mem/mem.log for this project.""" + try: + mem_root = Path(project_path) / ".mem" + mem_root.mkdir(parents=True, exist_ok=True) + log_path = mem_root / "mem.log" + + for handler in LOGGER.handlers: + if isinstance(handler, logging.FileHandler): + if Path(handler.baseFilename) == log_path: + return + + file_handler = logging.FileHandler(log_path, encoding="utf-8") + file_handler.setLevel(logging.INFO) + formatter = logging.Formatter( + "%(asctime)s %(levelname)s %(name)s - %(message)s" + ) + file_handler.setFormatter(formatter) + LOGGER.addHandler(file_handler) + LOGGER.setLevel(logging.INFO) + except Exception: + # Avoid breaking server if log file setup fails + return + + +def _build_history_context(history: list[dict]) -> tuple[str, dict, dict]: + lines = [] + short_to_full: dict[str, dict] = {} + short_to_entry: dict[str, dict] = {} + for entry in history: + short_hash = entry["short_hash"].lower() + prompt = (entry.get("prompt") or "N/A").replace("\n", " ").strip() + prompt = prompt[:160] + files = entry.get("files") or [] + files_preview = ", ".join(files[:5]) + op = entry.get("operation") or "snap" + branch = entry.get("branch") or "unknown" + line = f"[{short_hash}] {branch} | {op} | {prompt}" + if files_preview: + line += f" | files: {files_preview}" + lines.append(line) + short_to_full[short_hash] = { + "commit_hash": entry["commit_hash"], + "commit_short": entry["short_hash"], + } + short_to_entry[short_hash] = entry + return "\n".join(lines), short_to_full, short_to_entry + + +def _answer_indicates_no_info(answer: str) -> bool: + lowered = answer.strip().lower() + if not lowered: + return True + # English patterns + english_markers = [ + "no information", + "no relevant", + "not found", + "cannot find", + "could not find", + "unable to find", + "does not mention", + "no mention", + ] + # Chinese patterns + chinese_markers = [ + "没有", + "未找到", + "找不到", + "无法找到", + "无相关", + "未提及", + "没有信息", + ] + return any(marker in lowered for marker in english_markers) or any( + marker in answer for marker in chinese_markers + ) + + +def _parse_json_response(payload: str, label: str) -> dict: + import json + + if not payload or not payload.strip(): + raise HTTPException(status_code=502, detail=f"{label} JSON invalid: empty response") + try: + return json.loads(payload) + except json.JSONDecodeError: + cleaned = payload.strip() + start = cleaned.find("{") + end = cleaned.rfind("}") + if start != -1 and end != -1 and end > start: + try: + return json.loads(cleaned[start : end + 1]) + except json.JSONDecodeError as e: + raise HTTPException(status_code=502, detail=f"{label} JSON invalid: {e.msg}") + raise HTTPException(status_code=502, detail=f"{label} JSON invalid: Expecting value") + + +def _build_history_context_limited( + history: list[dict], max_chars: int, max_commits: int +) -> tuple[str, dict, dict]: + if max_commits <= 0: + return "", {}, {} + recent_history = history[-max_commits:] if len(history) > max_commits else history + lines = [] + short_to_full: dict[str, dict] = {} + short_to_entry: dict[str, dict] = {} + total_len = 0 + for entry in recent_history: + short_hash = entry["short_hash"].lower() + prompt = (entry.get("prompt") or "N/A").replace("\n", " ").strip() + prompt = prompt[:160] + files = entry.get("files") or [] + files_preview = ", ".join(files[:5]) + op = entry.get("operation") or "snap" + branch = entry.get("branch") or "unknown" + line = f"[{short_hash}] {branch} | {op} | {prompt}" + if files_preview: + line += f" | files: {files_preview}" + line_len = len(line) + 1 + if total_len + line_len > max_chars: + break + lines.append(line) + total_len += line_len + short_to_full[short_hash] = { + "commit_hash": entry["commit_hash"], + "commit_short": entry["short_hash"], + } + short_to_entry[short_hash] = entry + return "\n".join(lines), short_to_full, short_to_entry def create_app(project_path: str) -> "FastAPI": """Create FastAPI application with routes.""" global _project_path _project_path = project_path + _ensure_mem_logger(project_path) app = FastAPI(title="MemoV Web UI", version="1.0.0") @@ -257,49 +470,53 @@ async def ai_search(request: AISearchRequest): history_context = "\n".join(history_text) # Build AI prompt - system_prompt = """You are an AI assistant helping users search their code history. -You will be given a list of commits with their prompts/messages. -Answer the user's question based on this history. Be concise. - -IMPORTANT: Your response must be in JSON format with two fields: -1. "answer": A concise answer to the user's question -2. "commit_ids": An array of relevant commit hashes (short 7-char format) mentioned in your answer - -Example format: -{ - "answer": "You fixed the login bug in commit abc1234...", - "commit_ids": ["abc1234", "def5678"] -}""" - - user_prompt = f"""Commit history (format: [hash] branch | prompt): - -{history_context} - -Question: {request.query} - -Remember to respond in JSON format with "answer" and "commit_ids" fields.""" + system_prompt = AI_SEARCH_SYSTEM_PROMPT + user_prompt = AI_SEARCH_USER_PROMPT_TEMPLATE.format( + history_context=history_context, + query=request.query, + ) try: if request.provider == "anthropic": - ai_response = await _call_anthropic(request.api_key, system_prompt, user_prompt) + raise HTTPException(status_code=400, detail="Anthropic provider is not supported for AI search.") elif request.provider == "openai": ai_response = await _call_openai(request.api_key, system_prompt, user_prompt) else: raise HTTPException(status_code=400, detail=f"Unknown provider: {request.provider}") # Parse JSON response - import json - try: - parsed = json.loads(ai_response) - answer = parsed.get("answer", ai_response) - commit_ids = parsed.get("commit_ids", []) - except json.JSONDecodeError: - # Fallback if AI doesn't return JSON - answer = ai_response + import re + parsed = _parse_json_response(ai_response, "AI response") + + if not isinstance(parsed, dict): + raise HTTPException(status_code=502, detail="AI response JSON must be an object.") + + if "answer" not in parsed or "commit_ids" not in parsed: + raise HTTPException(status_code=502, detail="AI response JSON must contain 'answer' and 'commit_ids'.") + + answer = parsed.get("answer") + commit_ids_raw = parsed.get("commit_ids") + + if not isinstance(answer, str): + raise HTTPException(status_code=502, detail="AI response 'answer' must be a string.") + + if not isinstance(commit_ids_raw, list): + raise HTTPException(status_code=502, detail="AI response 'commit_ids' must be an array.") + + commit_ids = [] + for item in commit_ids_raw: + if not isinstance(item, str): + raise HTTPException(status_code=502, detail="AI response 'commit_ids' items must be strings.") + normalized = item.strip().lower() + if not re.fullmatch(r"[a-f0-9]{7}", normalized): + raise HTTPException( + status_code=502, + detail=f"Invalid commit id '{item}'. Expected 7-char hex hash.", + ) + commit_ids.append(normalized) + + if _answer_indicates_no_info(answer): commit_ids = [] - # Try to extract commit hashes from the response - import re - commit_ids = re.findall(r'\b[a-f0-9]{7}\b', ai_response.lower()) # Convert short hashes to full commit hashes full_commit_ids = [] @@ -321,6 +538,247 @@ async def ai_search(request: AISearchRequest): LOGGER.error(f"AI search error: {e}") raise HTTPException(status_code=500, detail=str(e)) + @app.get("/api/skills") + async def get_skills(): + """Get cached AI skill summaries and feature clusters.""" + manager = MemovManager(project_path=_project_path) + if manager.check() is not MemStatus.SUCCESS: + raise HTTPException(status_code=400, detail="Memov not initialized") + + db = _get_skills_db(manager) + features = db.get_features() + return { + "features": [ + { + "id": f.feature_id, + "name": f.name, + "summary": f.summary, + "skill_title": f.skill_title, + "skill_content": f.skill_content, + "skill_label": f.skill_label, + "commit_ids": [c["commit_hash"] for c in f.commits], + "commit_shorts": [c["commit_short"] for c in f.commits], + } + for f in features + ] + } + + @app.post("/api/skills/refresh") + async def refresh_skills(request: SkillsRefreshRequest): + """Generate feature clusters and skills summaries using OpenAI.""" + manager = MemovManager(project_path=_project_path) + if manager.check() is not MemStatus.SUCCESS: + raise HTTPException(status_code=400, detail="Memov not initialized") + + history = manager.get_history(limit=2000, include_diff=False, diff_mode="none") + LOGGER.info(f"Skills refresh: history commits loaded={len(history)}") + if not history: + raise HTTPException(status_code=400, detail="No history available to summarize") + attempt_limits = [ + (800, 12000), + (400, 8000), + (200, 5000), + (100, 3000), + ] + history_context = "" + short_to_full: dict[str, dict] = {} + short_to_entry: dict[str, dict] = {} + + cluster_system_prompt = CLUSTER_SYSTEM_PROMPT + cluster_user_prompt = CLUSTER_USER_PROMPT_TEMPLATE.format( + history_context=history_context + ) + + cluster_response = "" + cluster_payload: Optional[dict] = None + last_error: Optional[HTTPException] = None + for max_commits, max_chars in attempt_limits: + history_context, short_to_full, short_to_entry = _build_history_context_limited( + history, max_chars=max_chars, max_commits=max_commits + ) + if not history_context: + LOGGER.warning( + f"Skills refresh: empty history context for max_commits={max_commits}, max_chars={max_chars}" + ) + continue + LOGGER.info( + "Skills refresh: clustering attempt " + f"max_commits={max_commits}, max_chars={max_chars}, " + f"context_len={len(history_context)}" + ) + cluster_user_prompt = CLUSTER_USER_PROMPT_TEMPLATE.format( + history_context=history_context + ) + + try: + cluster_response = await _call_openai( + request.api_key, cluster_system_prompt, cluster_user_prompt, True + ) + if not cluster_response: + LOGGER.warning("Skills refresh: empty cluster response, retrying without JSON format.") + cluster_response = await _call_openai( + request.api_key, cluster_system_prompt, cluster_user_prompt, False + ) + LOGGER.info( + "Skills refresh: cluster response received " + f"len={len(cluster_response)} preview={cluster_response[:200]!r}" + ) + cluster_payload = _parse_json_response(cluster_response, "Cluster") + last_error = None + break + except HTTPException as e: + if e.status_code == 502 and "Cluster JSON invalid" in str(e.detail): + LOGGER.warning( + "Skills refresh: cluster parse error, will retry. " + f"detail={e.detail} raw_len={len(cluster_response)} raw={cluster_response!r}" + ) + last_error = e + continue + raise + except httpx.HTTPStatusError as e: + raise HTTPException( + status_code=e.response.status_code, detail=f"API error: {e.response.text}" + ) + except Exception as e: + LOGGER.error(f"Skills cluster error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + if last_error is not None: + LOGGER.error(f"Skills refresh: failed after retries. last_error={last_error.detail}") + raise last_error + if cluster_payload is None: + LOGGER.error("Skills refresh: cluster payload is None after retries.") + raise HTTPException(status_code=502, detail="Cluster JSON invalid: empty response") + + import re + + if not isinstance(cluster_payload, dict) or "features" not in cluster_payload: + LOGGER.error( + f"Skills refresh: cluster payload missing 'features'. keys={list(cluster_payload.keys())}" + ) + raise HTTPException(status_code=502, detail="Cluster JSON must include 'features'.") + + features = cluster_payload.get("features") + if not isinstance(features, list): + LOGGER.error("Skills refresh: cluster payload 'features' is not a list.") + raise HTTPException(status_code=502, detail="'features' must be an array.") + + db = _get_skills_db(manager) + db.reset() + + for feature in features: + if not isinstance(feature, dict): + raise HTTPException(status_code=502, detail="Feature entries must be objects.") + name = feature.get("name") + summary = feature.get("summary") + commit_ids = feature.get("commit_ids") + if not isinstance(name, str) or not name.strip(): + raise HTTPException(status_code=502, detail="Feature 'name' must be a string.") + if not isinstance(summary, str) or not summary.strip(): + raise HTTPException(status_code=502, detail="Feature 'summary' must be a string.") + if not isinstance(commit_ids, list) or not commit_ids: + raise HTTPException(status_code=502, detail="Feature 'commit_ids' must be a non-empty array.") + + normalized_ids = [] + for item in commit_ids: + if not isinstance(item, str): + raise HTTPException(status_code=502, detail="Commit ids must be strings.") + commit_short = item.strip().lower() + if not re.fullmatch(r"[a-f0-9]{7}", commit_short): + raise HTTPException( + status_code=502, + detail=f"Invalid commit id '{item}'. Expected 7-char hex.", + ) + if commit_short not in short_to_full: + raise HTTPException( + status_code=502, + detail=f"Commit id '{item}' not found in history.", + ) + normalized_ids.append(commit_short) + + feature_id = db.insert_feature(name.strip(), summary.strip()) + commits_payload = [short_to_full[cid] for cid in normalized_ids] + db.set_feature_commits(feature_id, commits_payload) + + # Generate skill docs per feature + features_for_prompt = db.get_features() + for feature in features_for_prompt: + commit_lines = [] + for commit in feature.commits: + entry = short_to_entry.get(commit["commit_short"].lower()) + if entry: + prompt = (entry.get("prompt") or "N/A").replace("\n", " ").strip() + prompt = prompt[:180] + branch = entry.get("branch") or "unknown" + op = entry.get("operation") or "snap" + files = entry.get("files") or [] + files_preview = ", ".join(files[:5]) + line = f"[{commit['commit_short']}] {branch} | {op} | {prompt}" + if files_preview: + line += f" | files: {files_preview}" + commit_lines.append(line) + else: + commit_lines.append(f"[{commit['commit_short']}] {commit['commit_hash']}") + commits_text = "\n".join(commit_lines) + + skill_system_prompt = SKILL_SYSTEM_PROMPT + skill_user_prompt = SKILL_USER_PROMPT_TEMPLATE.format( + feature_name=feature.name, + feature_summary=feature.summary, + commits_text=commits_text, + ) + + try: + skill_response = await _call_openai( + request.api_key, skill_system_prompt, skill_user_prompt, True + ) + if not skill_response: + LOGGER.warning("Skills refresh: empty skill response, retrying without JSON format.") + skill_response = await _call_openai( + request.api_key, skill_system_prompt, skill_user_prompt, False + ) + except httpx.HTTPStatusError as e: + raise HTTPException( + status_code=e.response.status_code, detail=f"API error: {e.response.text}" + ) + except Exception as e: + LOGGER.error(f"Skills summary error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + skill_payload = _parse_json_response(skill_response, "Skill") + + if not isinstance(skill_payload, dict): + raise HTTPException(status_code=502, detail="Skill JSON must be an object.") + + title = skill_payload.get("title") + content = skill_payload.get("content") + label = skill_payload.get("label") + if not isinstance(title, str) or not title.strip(): + raise HTTPException(status_code=502, detail="Skill 'title' must be a string.") + if not isinstance(content, str) or not content.strip(): + raise HTTPException(status_code=502, detail="Skill 'content' must be a string.") + if not isinstance(label, str) or not label.strip(): + raise HTTPException(status_code=502, detail="Skill 'label' must be a string.") + + db.set_skill_doc(feature.feature_id, title.strip(), content.strip(), label.strip()) + + refreshed = db.get_features() + return { + "features": [ + { + "id": f.feature_id, + "name": f.name, + "summary": f.summary, + "skill_title": f.skill_title, + "skill_content": f.skill_content, + "skill_label": f.skill_label, + "commit_ids": [c["commit_hash"] for c in f.commits], + "commit_shorts": [c["commit_short"] for c in f.commits], + } + for f in refreshed + ] + } + # Serve static files static_dir = Path(__file__).parent / "static" if static_dir.exists(): diff --git a/memov/web/static/index.html b/memov/web/static/index.html index 1d816d9..54060f0 100644 --- a/memov/web/static/index.html +++ b/memov/web/static/index.html @@ -81,7 +81,8 @@ letter-spacing: 0.5px; } - .branch-item { + .branch-item, + .skill-item { padding: 6px 10px; margin-bottom: 2px; border-radius: 4px; @@ -90,22 +91,26 @@ color: #555; } - .branch-item:hover { + .branch-item:hover, + .skill-item:hover { background: #f0f0f0; } - .branch-item.current { + .branch-item.current, + .skill-item.current { background: #333; color: white; } - .branch-item .count { + .branch-item .count, + .skill-item .count { float: right; color: #aaa; font-size: 11px; } - .branch-item.current .count { + .branch-item.current .count, + .skill-item.current .count { color: #ccc; } @@ -372,7 +377,7 @@ border-radius: 10px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); max-height: 350px; - overflow: hidden; + overflow: auto; z-index: 1000; } @@ -464,6 +469,15 @@ border-color: #4caf50; } + .search-result-item.ai-summary { + cursor: default; + } + + .search-result-item.ai-summary:hover { + background: rgba(255, 255, 255, 0.6); + border-color: rgba(0, 0, 0, 0.08); + } + .search-result-item .item-header { display: flex; align-items: center; @@ -509,6 +523,11 @@ white-space: nowrap; } + .search-result-item.ai-summary .item-prompt { + white-space: normal; + line-height: 1.5; + } + .search-result-item .item-files { display: flex; flex-wrap: wrap; @@ -1121,6 +1140,52 @@ border-color: #333; } + .all-view-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + } + + .view-switch { + display: flex; + flex: 1; + background: #f5f5f5; + border: 1px solid #ddd; + border-radius: 10px; + padding: 2px; + } + + .view-switch-btn { + flex: 1; + padding: 6px 8px; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + font-size: 11px; + font-weight: 600; + color: #666; + text-transform: uppercase; + letter-spacing: 0.4px; + transition: all 0.15s; + } + + .view-switch-btn:hover { + color: #333; + } + + .view-switch-btn.active { + background: #fff; + color: #111; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); + } + + .view-switch-btn:disabled { + cursor: not-allowed; + opacity: 0.5; + } + /* Tree view container */ .tree-view { padding: 20px; @@ -1219,6 +1284,287 @@ text-anchor: middle; } + /* Skills view */ + .skills-view { + padding: 20px; + } + + .skills-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + } + + .skills-toolbar .hint { + font-size: 12px; + color: #888; + } + + .skills-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; + } + + .skills-card { + background: #fff; + border: 1px solid #eee; + border-radius: 12px; + padding: 14px; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0,0,0,0.04); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; + } + + .skills-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(0,0,0,0.08); + border-color: #e0e0e0; + } + + .skills-card-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + } + + .skills-card-icon { + width: 34px; + height: 28px; + border-radius: 6px; + background: linear-gradient(135deg, #f7d774, #f2b84b); + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + } + + .skills-card-icon img { + width: 18px; + height: 18px; + display: block; + } + + .skills-card-title { + font-size: 13px; + font-weight: 600; + color: #333; + } + + .skills-card-summary { + font-size: 12px; + color: #666; + line-height: 1.4; + margin-bottom: 10px; + min-height: 36px; + } + + .skills-card-meta { + font-size: 11px; + color: #999; + } + + .skills-card-meta + .skills-card-meta { + margin-top: 6px; + } + + .skills-tag { + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + color: #fff; + white-space: nowrap; + } + + .skill-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; + opacity: 0.6; + } + + .skills-label-text { + font-size: 11px; + } + + .skills-modal-content { + max-width: 720px; + } + + .skills-detail-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + } + + .skills-detail-title { + font-size: 18px; + font-weight: 600; + color: #222; + } + + .skills-detail-section { + margin-bottom: 16px; + } + + .skills-detail-section h4 { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.6px; + color: #888; + margin: 0 0 6px 0; + } + + .skills-detail-section p { + font-size: 13px; + color: #444; + line-height: 1.5; + margin: 0; + white-space: pre-wrap; + } + + .skills-commit-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .skills-commit-item { + border: 1px solid #eee; + border-radius: 8px; + padding: 10px 12px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + } + + .skills-commit-item:hover { + border-color: #ccc; + background: #fafafa; + } + + .skills-commit-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; + } + + .skills-commit-hash { + font-family: 'SF Mono', Consolas, monospace; + font-size: 11px; + background: #f5f5f5; + padding: 2px 6px; + border-radius: 3px; + color: #555; + } + + .skills-commit-branch { + font-size: 10px; + background: #e8f5e9; + color: #2e7d32; + padding: 2px 8px; + border-radius: 10px; + } + + .skills-commit-time { + font-size: 11px; + color: #aaa; + margin-left: auto; + } + + .skills-commit-prompt { + font-size: 12px; + color: #555; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .skills-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 16px; + } + + .skills-pagination .page-info { + font-size: 12px; + color: #777; + } + + + .coachmark { + position: absolute; + background: rgba(255, 255, 255, 0.95); + color: #1f1f1f; + padding: 12px 14px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + border: 1px solid rgba(0,0,0,0.06); + box-shadow: 0 12px 30px rgba(0,0,0,0.12); + z-index: 2000; + opacity: 0; + transform: translateY(6px); + transition: opacity 0.2s ease, transform 0.2s ease; + pointer-events: none; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + } + + .coachmark.show { + opacity: 1; + transform: translateY(0); + } + + .coachmark::before { + content: ''; + position: absolute; + left: 0; + top: 8px; + width: 3px; + height: calc(100% - 16px); + background: linear-gradient(180deg, #0a84ff, #5ac8fa); + border-radius: 3px; + } + + .coachmark::after { + content: ''; + position: absolute; + top: -7px; + left: 18px; + border-width: 0 7px 7px 7px; + border-style: solid; + border-color: transparent transparent rgba(255, 255, 255, 0.95) transparent; + filter: drop-shadow(0 -1px 1px rgba(0,0,0,0.08)); + } + + .pulse-ring { + position: absolute; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid rgba(10, 132, 255, 0.9); + animation: pulse 1.4s infinite; + z-index: 2000; + pointer-events: none; + } + + @keyframes pulse { + 0% { transform: scale(1); opacity: 0.8; } + 70% { transform: scale(2.2); opacity: 0; } + 100% { opacity: 0; } + } + /* Header right section */ .header-right { display: flex; @@ -1421,6 +1767,11 @@ text-overflow: ellipsis; } + .search-result-item.ai-summary .search-result-prompt { + white-space: normal; + line-height: 1.5; + } + .search-result-match { background: #fff59d; padding: 0 2px; @@ -1491,7 +1842,12 @@

MemoV Timeline

- +
+
+ + +
+
Loading...
@@ -1542,10 +1898,9 @@

Branches

- +
@@ -1598,6 +1953,20 @@

Commit Details

+ + + +
+
+