diff --git a/backend/apps/help/changelog.py b/backend/apps/help/changelog.py
new file mode 100644
index 00000000..1f557afc
--- /dev/null
+++ b/backend/apps/help/changelog.py
@@ -0,0 +1,90 @@
+"""The release story, in one place, for three surfaces: the in-app What's New card, the GitHub
+release body, and the Help agent's context. One source means the agent can never answer from a
+stale picture of the app, and a release can never ship with no story."""
+
+from typing import Dict, List
+
+from pydantic import BaseModel, ConfigDict
+from typeguard import typechecked
+
+
+class ReleaseNote(BaseModel):
+ model_config = ConfigDict(validate_assignment=True)
+ version: str
+ headline: str
+ # User-facing lines only: what changed for the person using the app, not the diff.
+ highlights: List[str]
+ fixes: List[str]
+
+
+P_RELEASES: List[ReleaseNote] = [
+ ReleaseNote(
+ version="1.7.5",
+ headline="Fewer dead ends, and the app tells us when something breaks.",
+ highlights=[
+ "Scrolling inside any panel, chat, or browser stays in that panel instead of dragging the canvas.",
+ "Clicking the Marketplace window frames it like every other window, and camera moves land softly instead of snapping.",
+ "While a browser agent works you see the live mini browser only, not the same action log twice.",
+ ],
+ fixes=[
+ "Dictation lands in the field you started in, even if you click elsewhere while it is still transcribing.",
+ "The first message after opening a chat reuses the warmed-up connection, so it answers sooner.",
+ "A provider hiccup that fixes itself no longer shows a scary reconnect card.",
+ "Crashes, freezes, and runaway memory now report themselves, so bugs get diagnosed instead of guessed at.",
+ ],
+ ),
+ ReleaseNote(
+ version="1.7.4",
+ headline="Chats survive a hiccup instead of stopping.",
+ highlights=[
+ "A dropped local connection retries and resumes the same answer instead of failing the message.",
+ "The spawn composer steps aside when a window is open.",
+ ],
+ fixes=[
+ "App previews reconnect on their own after a backend restart.",
+ "Dictation cue sounds default to a level you can actually hear.",
+ ],
+ ),
+]
+
+
+@typechecked
+def release_notes(version: str) -> ReleaseNote | None:
+ for note in P_RELEASES:
+ if note.version == version:
+ return note
+ return None
+
+
+@typechecked
+def latest_release() -> ReleaseNote:
+ return P_RELEASES[0]
+
+
+@typechecked
+def as_markdown(note: ReleaseNote) -> str:
+ """The GitHub release body; identical words to the in-app card, so nobody reads two stories."""
+ lines = [f"## {note.version} — {note.headline}", ""]
+ if note.highlights:
+ lines.append("### New")
+ lines += [f"- {h}" for h in note.highlights]
+ lines.append("")
+ if note.fixes:
+ lines.append("### Fixed")
+ lines += [f"- {f}" for f in note.fixes]
+ return "\n".join(lines).strip()
+
+
+@typechecked
+def help_context_block(app_version: str) -> str:
+ """What the Help agent must know about what just changed, so "what's new" is never stale."""
+ note = release_notes(app_version) or latest_release()
+ body = [f"Version {note.version}: {note.headline}"]
+ body += [f"- new: {h}" for h in note.highlights]
+ body += [f"- fixed: {f}" for f in note.fixes]
+ return "\n".join(body)
+
+
+@typechecked
+def all_versions() -> Dict[str, str]:
+ return {n.version: n.headline for n in P_RELEASES}
diff --git a/backend/apps/help/knowledge.py b/backend/apps/help/knowledge.py
index 78db0d59..10778761 100644
--- a/backend/apps/help/knowledge.py
+++ b/backend/apps/help/knowledge.py
@@ -136,6 +136,9 @@ def p_issues_block() -> str:
@typechecked
+from backend.apps.help.changelog import help_context_block
+
+
def build_system_prompt(shortcuts: List[HelpShortcut], app_version: str) -> str:
shortcut_lines = "\n".join(f"- {s.keys}: {s.action}" for s in shortcuts)
os_name = "macOS" if IS_MAC else platform.system()
@@ -158,6 +161,11 @@ def build_system_prompt(shortcuts: List[HelpShortcut], app_version: str) -> str:
shortcut_lines,
"",
"",
+ "",
+ "What actually changed in this build. Answer \"what's new\" from THIS, never from memory.",
+ help_context_block(app_version),
+ "",
+ "",
"",
"The complete list of issues shipped with this build. You cannot see live bug reports.",
p_issues_block(),
diff --git a/backend/tests/test_changelog.py b/backend/tests/test_changelog.py
new file mode 100644
index 00000000..4140c27b
--- /dev/null
+++ b/backend/tests/test_changelog.py
@@ -0,0 +1,43 @@
+"""One release story, three surfaces. A release with no story, or a Help agent answering from a
+stale picture of the app, are both bugs this pins shut."""
+
+from backend.apps.help.changelog import (
+ all_versions, as_markdown, help_context_block, latest_release, release_notes,
+)
+from backend.apps.service.version import APP_VERSION
+
+
+def test_the_shipping_version_has_a_story():
+ note = release_notes(APP_VERSION)
+ assert note is not None, f"{APP_VERSION} ships with no release notes; write them before tagging"
+ assert note.headline and (note.highlights or note.fixes)
+
+
+def test_notes_are_written_for_users_not_committers():
+ for note in (latest_release(),):
+ for line in note.highlights + note.fixes:
+ assert not line.startswith("["), "no commit-style prefixes"
+ assert "commit" not in line.lower() and "refactor" not in line.lower()
+ assert "—" not in line and "–" not in line, "house style: no em/en dashes"
+
+
+def test_markdown_body_carries_the_same_words_as_the_app():
+ note = latest_release()
+ md = as_markdown(note)
+ assert note.headline in md
+ for line in note.highlights + note.fixes:
+ assert line in md, "the GitHub body must not drift from the in-app card"
+
+
+def test_help_context_names_the_version_and_its_changes():
+ block = help_context_block(APP_VERSION)
+ assert APP_VERSION in block
+ note = release_notes(APP_VERSION)
+ assert note is not None
+ assert note.highlights[0] in block
+
+
+def test_unknown_version_falls_back_to_the_latest_story_not_silence():
+ assert release_notes("0.0.0") is None
+ assert latest_release().version in help_context_block("0.0.0")
+ assert len(all_versions()) >= 2