diff --git a/hindsight-dev/hindsight_dev/generate_changelog.py b/hindsight-dev/hindsight_dev/generate_changelog.py new file mode 100644 index 00000000..8cef9821 --- /dev/null +++ b/hindsight-dev/hindsight_dev/generate_changelog.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +""" +Generate changelog entry for a new release. + +This script fetches the commit diff between releases, uses an LLM to summarize, +and prepends the entry to the changelog page. +""" +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +from openai import OpenAI +from pydantic import BaseModel +from rich.console import Console + +console = Console() + +GITHUB_REPO = "vectorize-io/hindsight" +GITHUB_RELEASES_URL = f"https://github.com/{GITHUB_REPO}/releases" +GITHUB_COMMIT_URL = f"https://github.com/{GITHUB_REPO}/commit" +REPO_PATH = Path(__file__).parent.parent.parent +CHANGELOG_PATH = REPO_PATH / "hindsight-docs" / "docs" / "changelog" / "index.md" + + +class ChangelogEntry(BaseModel): + """A single changelog entry.""" + category: str # "feature", "improvement", "bugfix", "breaking", "other" + summary: str # Brief description of the change + commit_id: str # Short commit hash + + +class ChangelogResponse(BaseModel): + """Structured response from LLM.""" + entries: list[ChangelogEntry] + + +@dataclass +class Commit: + """Parsed commit from git log.""" + hash: str + message: str + + +def parse_semver(version: str) -> tuple[int, int, int]: + """Parse a semver string into (major, minor, patch).""" + version = version.lstrip("v") + match = re.match(r"^(\d+)\.(\d+)\.(\d+)", version) + if not match: + raise ValueError(f"Invalid semver: {version}") + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def get_git_tags() -> list[str]: + """Get all git tags sorted by semver (newest first).""" + result = subprocess.run( + ["git", "tag"], + cwd=REPO_PATH, + capture_output=True, + text=True, + check=True, + ) + tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()] + + valid_tags = [] + for tag in tags: + try: + parse_semver(tag) + valid_tags.append(tag) + except ValueError: + continue + + valid_tags.sort(key=lambda t: parse_semver(t), reverse=True) + return valid_tags + + +def find_previous_version(new_version: str, existing_tags: list[str]) -> str | None: + """Find the previous version based on semver rules.""" + new_major, new_minor, new_patch = parse_semver(new_version) + + candidates = [] + for tag in existing_tags: + try: + major, minor, patch = parse_semver(tag) + except ValueError: + continue + + if (major, minor, patch) >= (new_major, new_minor, new_patch): + continue + + candidates.append((tag, (major, minor, patch))) + + if not candidates: + return None + + candidates.sort(key=lambda x: x[1], reverse=True) + return candidates[0][0] + + +def get_commits(from_ref: str | None, to_ref: str) -> list[Commit]: + """Get commits between two refs as structured data.""" + if from_ref: + cmd = ["git", "log", "--format=%h|%s", "--no-merges", f"{from_ref}..{to_ref}"] + else: + cmd = ["git", "log", "--format=%h|%s", "--no-merges", to_ref] + + result = subprocess.run( + cmd, + cwd=REPO_PATH, + capture_output=True, + text=True, + check=True, + ) + + commits = [] + for line in result.stdout.strip().split("\n"): + if not line: + continue + parts = line.split("|", 1) + if len(parts) == 2: + commits.append(Commit(hash=parts[0], message=parts[1])) + + return commits + + +def get_detailed_diff(from_ref: str | None, to_ref: str) -> str: + """Get file change stats between two refs.""" + if from_ref: + cmd = ["git", "diff", "--stat", f"{from_ref}..{to_ref}"] + else: + cmd = ["git", "diff", "--stat", f"{to_ref}^..{to_ref}"] + + result = subprocess.run( + cmd, + cwd=REPO_PATH, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def analyze_commits_with_llm( + client: OpenAI, + model: str, + version: str, + commits: list[Commit], + file_diff: str, +) -> list[ChangelogEntry]: + """Use LLM to analyze commits and return structured changelog entries.""" + commits_json = json.dumps( + [{"commit_id": c.hash, "message": c.message} for c in commits], + indent=2 + ) + + prompt = f"""Analyze the following git commits for release {version} of Hindsight (an AI memory system). + +For each meaningful change, create a changelog entry with: +- category: one of "feature", "improvement", "bugfix", "breaking", "other" +- summary: brief one-line description of the change (user-facing, not technical) +- commit_id: the commit hash from the input + +Rules: +- Group related commits into a single entry if they're part of the same change +- Skip trivial changes (typo fixes, formatting, internal refactoring) +- Skip repository-only changes: README updates, CI/GitHub Actions, release scripts, changelog updates, version bumps +- Focus on user-facing changes that affect the product functionality +- Use the exact commit_id from the input (pick the most relevant one if grouping) +- If no meaningful changes remain after filtering, return an empty list + +Commits: +{commits_json} + +Files changed summary: +{file_diff[:4000]}""" + + response = client.beta.chat.completions.parse( + model=model, + messages=[{"role": "user", "content": prompt}], + response_format=ChangelogResponse, + max_completion_tokens=16000, + ) + + return response.choices[0].message.parsed.entries + + +def build_changelog_markdown( + version: str, + tag: str, + entries: list[ChangelogEntry], +) -> str: + """Build markdown changelog from structured entries.""" + release_url = f"{GITHUB_RELEASES_URL}/tag/{tag}" + + # Group entries by category + categories = { + "breaking": ("Breaking Changes", []), + "feature": ("Features", []), + "improvement": ("Improvements", []), + "bugfix": ("Bug Fixes", []), + "other": ("Other", []), + } + + for entry in entries: + cat = entry.category.lower() + if cat in categories: + categories[cat][1].append(entry) + else: + categories["other"][1].append(entry) + + # Build markdown + lines = [f"## [{version}]({release_url})", ""] + + for cat_key in ["breaking", "feature", "improvement", "bugfix", "other"]: + cat_name, cat_entries = categories[cat_key] + if cat_entries: + lines.append(f"**{cat_name}**") + lines.append("") + for entry in cat_entries: + commit_url = f"{GITHUB_COMMIT_URL}/{entry.commit_id}" + lines.append(f"- {entry.summary} ([`{entry.commit_id}`]({commit_url}))") + lines.append("") + + return "\n".join(lines) + + +def read_existing_changelog() -> tuple[str, str]: + """Read existing changelog and split into header and content.""" + if not CHANGELOG_PATH.exists(): + header = """--- +sidebar_position: 1 +--- + +# Changelog + +For full release details, see [GitHub Releases](https://github.com/vectorize-io/hindsight/releases). +""" + return header, "" + + content = CHANGELOG_PATH.read_text() + + match = re.search(r"^## ", content, re.MULTILINE) + if match: + header = content[:match.start()].rstrip() + "\n\n" + releases = content[match.start():] + else: + header = content.rstrip() + "\n\n" + releases = "" + + return header, releases + + +def write_changelog(header: str, new_entry: str, existing_releases: str) -> None: + """Write changelog with new entry prepended.""" + content = header + new_entry + "\n" + existing_releases + CHANGELOG_PATH.parent.mkdir(parents=True, exist_ok=True) + CHANGELOG_PATH.write_text(content.rstrip() + "\n") + + +def generate_changelog_entry( + version: str, + llm_model: str = "gpt-5.2", +) -> None: + """Generate changelog entry for a specific version.""" + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + console.print("[red]Error: OPENAI_API_KEY environment variable not set[/red]") + sys.exit(1) + + client = OpenAI(api_key=api_key) + + tag = version if version.startswith("v") else f"v{version}" + display_version = version.lstrip("v") + + console.print(f"[blue]Fetching tags from repository...[/blue]") + existing_tags = get_git_tags() + + if tag not in existing_tags and display_version not in existing_tags: + console.print(f"[red]Error: Tag {tag} not found in repository[/red]") + console.print("[red]Create the tag first before generating changelog[/red]") + sys.exit(1) + + actual_tag = tag if tag in existing_tags else display_version + + previous_tag = find_previous_version(display_version, existing_tags) + + if previous_tag: + console.print(f"[green]Found previous version: {previous_tag}[/green]") + else: + console.print("[yellow]No previous version found, will include all commits[/yellow]") + + console.print(f"[blue]Getting commits...[/blue]") + commits = get_commits(previous_tag, actual_tag) + file_diff = get_detailed_diff(previous_tag, actual_tag) + + if not commits: + console.print("[red]Error: No commits found for this release[/red]") + sys.exit(1) + + console.print(f"[blue]Found {len(commits)} commits[/blue]") + + # Log commits + console.print("\n[bold]Commits:[/bold]") + for c in commits: + console.print(f" {c.hash} {c.message}") + + console.print("\n[bold]Files changed:[/bold]") + console.print(file_diff[:4000] if len(file_diff) > 4000 else file_diff) + console.print("") + + console.print(f"[blue]Analyzing commits with LLM ({llm_model})...[/blue]") + entries = analyze_commits_with_llm(client, llm_model, display_version, commits, file_diff) + + console.print(f"\n[bold]LLM identified {len(entries)} changelog entries:[/bold]") + for entry in entries: + console.print(f" [{entry.category}] {entry.summary} ({entry.commit_id})") + + new_entry = build_changelog_markdown(display_version, tag, entries) + + header, existing_releases = read_existing_changelog() + + if f"## [{display_version}]" in existing_releases: + console.print(f"[red]Error: Version {display_version} already exists in changelog[/red]") + sys.exit(1) + + write_changelog(header, new_entry, existing_releases) + + console.print(f"\n[green]Changelog updated: {CHANGELOG_PATH}[/green]") + console.print(f"\n[bold]New entry:[/bold]\n{new_entry}") + + +def main(): + parser = argparse.ArgumentParser( + description="Generate changelog entry for a release", + usage="generate-changelog VERSION [--model MODEL]", + ) + parser.add_argument( + "version", + help="Version to generate changelog for (e.g., 1.0.5, v1.0.5)", + ) + parser.add_argument( + "--model", + default="gpt-5.2", + help="OpenAI model to use (default: gpt-5.2)", + ) + + args = parser.parse_args() + + generate_changelog_entry( + version=args.version, + llm_model=args.model, + ) + + +if __name__ == "__main__": + main() diff --git a/hindsight-dev/pyproject.toml b/hindsight-dev/pyproject.toml index 24f01a13..3ffccc69 100644 --- a/hindsight-dev/pyproject.toml +++ b/hindsight-dev/pyproject.toml @@ -24,3 +24,4 @@ hindsight-api = { workspace = true } [project.scripts] generate-openapi = "hindsight_dev.generate_openapi:generate_openapi_spec" +generate-changelog = "hindsight_dev.generate_changelog:main" diff --git a/hindsight-docs/docs/changelog/index.md b/hindsight-docs/docs/changelog/index.md index d41ddf06..33a7c839 100644 --- a/hindsight-docs/docs/changelog/index.md +++ b/hindsight-docs/docs/changelog/index.md @@ -4,4 +4,35 @@ sidebar_position: 1 # Changelog -Coming soon. +For full release details, see [GitHub Releases](https://github.com/vectorize-io/hindsight/releases). + +## [0.1.5](https://github.com/vectorize-io/hindsight/releases/tag/v0.1.5) + +**Features** + +- Added LiteLLM integration so Hindsight can capture and manage memories from LiteLLM-based LLM calls. ([`dfccbf2`](https://github.com/vectorize-io/hindsight/commit/dfccbf2)) +- Added an optional graph-based retriever (MPFP) to improve recall by leveraging relationships between memories. ([`7445cef`](https://github.com/vectorize-io/hindsight/commit/7445cef)) + +**Improvements** + +- Switched the embedded Postgres layer to pg0-embedded for a smoother local/standalone experience. ([`94c2b85`](https://github.com/vectorize-io/hindsight/commit/94c2b85)) + +**Bug Fixes** + +- Fixed repeated retries on 400 errors from the LLM, preventing unnecessary request loops and failures. ([`70983f5`](https://github.com/vectorize-io/hindsight/commit/70983f5)) +- Fixed recall trace visualization in the control plane so search/recall debugging displays correctly. ([`922164e`](https://github.com/vectorize-io/hindsight/commit/922164e)) +- Fixed the CLI installer to make installation more reliable. ([`158a6aa`](https://github.com/vectorize-io/hindsight/commit/158a6aa)) +- Updated Next.js to patch security vulnerabilities (CVE-2025-55184, CVE-2025-55183). ([`f018cc5`](https://github.com/vectorize-io/hindsight/commit/f018cc5)) + +## [0.1.3](https://github.com/vectorize-io/hindsight/releases/tag/v0.1.3) + +**Improvements** + +- Improved CLI and UI branding/polish, including new banner/logo assets and updated interface styling. ([`fa554b8`](https://github.com/vectorize-io/hindsight/commit/fa554b8)) + + +## [0.1.2](https://github.com/vectorize-io/hindsight/releases/tag/v0.1.2) + +**Bug Fixes** + +- Fixed the standalone Docker image so it builds/runs correctly. ([`1056a20`](https://github.com/vectorize-io/hindsight/commit/1056a20)) diff --git a/hindsight-docs/static/llms-full.txt b/hindsight-docs/static/llms-full.txt index 3a62968b..9712ce98 100644 --- a/hindsight-docs/static/llms-full.txt +++ b/hindsight-docs/static/llms-full.txt @@ -3,7 +3,7 @@ > Agent Memory that Works Like Human Memory This file contains the complete Hindsight documentation for LLM consumption. -Generated: 2025-12-15T10:18:45.836Z +Generated: 2025-12-15T11:03:02.071Z --- @@ -4081,7 +4081,7 @@ Known Solutions: # Changelog -Coming soon. +For full release details, see [GitHub Releases](https://github.com/vectorize-io/hindsight/releases). --- diff --git a/scripts/dev/generate-changelog.sh b/scripts/dev/generate-changelog.sh new file mode 100755 index 00000000..91ccd0bc --- /dev/null +++ b/scripts/dev/generate-changelog.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +cd "$(dirname "$0")/../.." + +if [ -z "$1" ]; then + echo "Usage: $0 VERSION [--model MODEL]" + echo "" + echo "Generate changelog entry for a release." + echo "" + echo "Examples:" + echo " $0 1.0.5" + echo " $0 v1.0.5" + echo " $0 1.0.5 --model gpt-4o" + exit 1 +fi + +if [ -z "$OPENAI_API_KEY" ]; then + ENV_FILE=".env" + if [ -f "$ENV_FILE" ]; then + echo "Loading environment from $ENV_FILE" + set -a + source "$ENV_FILE" + set +a + else + echo "Error: OPENAI_API_KEY not set and no .env file found" + exit 1 + fi +fi + +cd hindsight-dev +uv run generate-changelog "$@" diff --git a/uv.lock b/uv.lock index 51ddc6cf..a65c418d 100644 --- a/uv.lock +++ b/uv.lock @@ -1141,7 +1141,7 @@ wheels = [ [[package]] name = "hindsight-all" -version = "0.1.4" +version = "0.1.5" source = { editable = "hindsight" } dependencies = [ { name = "hindsight-api" }, @@ -1165,7 +1165,7 @@ provides-extras = ["test"] [[package]] name = "hindsight-api" -version = "0.1.4" +version = "0.1.5" source = { editable = "hindsight-api" } dependencies = [ { name = "alembic" }, @@ -1223,7 +1223,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.29.0" }, { name = "dateparser", specifier = ">=1.2.2" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.120.3" }, - { name = "fastmcp", specifier = ">=2.0.0" }, + { name = "fastmcp", specifier = ">=2.3.0" }, { name = "filelock", marker = "extra == 'test'", specifier = ">=3.0.0" }, { name = "google-genai", specifier = ">=1.0.0" }, { name = "greenlet", specifier = ">=3.2.4" }, @@ -1267,7 +1267,7 @@ dev = [ [[package]] name = "hindsight-client" -version = "0.1.4" +version = "0.1.5" source = { editable = "hindsight-clients/python" } dependencies = [ { name = "aiohttp" }, @@ -1299,7 +1299,7 @@ provides-extras = ["test"] [[package]] name = "hindsight-dev" -version = "0.1.4" +version = "0.1.5" source = { editable = "hindsight-dev" } dependencies = [ { name = "hindsight-api" },