From e652dece9ba0e447c55baa11448fa3c3565c2f5e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Apr 2026 16:28:25 -0400 Subject: [PATCH 01/23] refactor: rename Fabled Assistant to Fabled Scribe Updates user-visible branding across frontend (PWA manifest, page title, Settings, push fallback), backend (email templates and subjects, LLM system prompt, CalDAV displayname, SMTP from-name default), README, quickstart compose, and MCP server description. Also updates the CI image path and quickstart image reference to git.fabledsword.com/bvandeusen/fabledscribe in preparation for the Forgejo repo rename. Internal Python package, env vars, and database schema unchanged. Co-Authored-By: Claude Opus 4.7 --- .forgejo/workflows/ci.yml | 2 +- README.md | 2 +- docker-compose.quickstart.yml | 4 ++-- fable-mcp/pyproject.toml | 2 +- frontend/index.html | 4 ++-- frontend/public/manifest.json | 4 ++-- frontend/public/sw.js | 2 +- frontend/src/views/SettingsView.vue | 6 +++--- src/fabledassistant/config.py | 2 +- src/fabledassistant/services/calendar_sync.py | 2 +- src/fabledassistant/services/email.py | 12 ++++++------ src/fabledassistant/services/llm.py | 2 +- src/fabledassistant/services/notifications.py | 12 ++++++------ 13 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index f657f3e..4ff4ecb 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -57,7 +57,7 @@ permissions: env: REGISTRY: git.fabledsword.com - IMAGE: git.fabledsword.com/bvandeusen/fabledassistant + IMAGE: git.fabledsword.com/bvandeusen/fabledscribe jobs: typecheck: diff --git a/README.md b/README.md index a329927..5af628f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Fabled Assistant +# Fabled Scribe A self-hosted second brain and project management application with integrated LLM capabilities. Write, organise, and act on your notes and tasks with the help of a local AI assistant — all running on your own hardware. diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index a5a294f..e2a9ee5 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -1,4 +1,4 @@ -# Fabled Assistant — Quick Start +# Fabled Scribe — Quick Start # # No build required. Pulls the latest pre-built image from the registry. # @@ -13,7 +13,7 @@ services: app: - image: git.fabledsword.com/bvandeusen/fabledassistant:latest + image: git.fabledsword.com/bvandeusen/fabledscribe:latest ports: - "5000:5000" environment: diff --git a/fable-mcp/pyproject.toml b/fable-mcp/pyproject.toml index ba6f5a3..d3212e8 100644 --- a/fable-mcp/pyproject.toml +++ b/fable-mcp/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "fable-mcp" version = "0.2.6" -description = "MCP server for Fabled Assistant" +description = "MCP server for Fabled Scribe" requires-python = ">=3.12" dependencies = [ "mcp[cli]>=1.0", diff --git a/frontend/index.html b/frontend/index.html index 3cde003..6fdfe7a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,8 +9,8 @@ - - Fabled Assistant + + Fabled Scribe
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 463e6d0..d374868 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -1,6 +1,6 @@ { - "name": "Fabled Assistant", - "short_name": "Fabled", + "name": "Fabled Scribe", + "short_name": "Scribe", "description": "Your self-hosted second brain with AI assistance", "start_url": "/", "display": "standalone", diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 246a13b..5ce54d6 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -12,7 +12,7 @@ self.addEventListener('push', event => { return; } } - return self.registration.showNotification(data.title || 'Fabled Assistant', { + return self.registration.showNotification(data.title || 'Scribe', { body: data.body || '', icon: '/favicon.ico', badge: '/favicon.ico', diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 5bdcff1..6ce18c7 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -501,7 +501,7 @@ const smtp = ref({ smtp_username: "", smtp_password: "", smtp_from_address: "", - smtp_from_name: "Fabled Assistant", + smtp_from_name: "Fabled Scribe", smtp_use_tls: "true", }); const savingSmtp = ref(false); @@ -1981,7 +1981,7 @@ function formatUserDate(iso: string): string {

About

-

Fabled Assistant {{ appVersion }}

+

Fabled Scribe {{ appVersion }}

@@ -2741,7 +2741,7 @@ FABLE_API_KEY={{ effectiveApiKey }}
- +
diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py index 7c447d0..09740e9 100644 --- a/src/fabledassistant/config.py +++ b/src/fabledassistant/config.py @@ -52,7 +52,7 @@ class Config: SMTP_USERNAME: str = os.environ.get("SMTP_USERNAME", "") SMTP_PASSWORD: str = _read_secret("SMTP_PASSWORD", "SMTP_PASSWORD_FILE", "") SMTP_FROM_ADDRESS: str = os.environ.get("SMTP_FROM_ADDRESS", "") - SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Assistant") + SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Scribe") SMTP_USE_TLS: bool = os.environ.get("SMTP_USE_TLS", "true").lower() in ("1", "true", "yes") BASE_URL: str = os.environ.get("BASE_URL", "http://localhost:5000").rstrip("/") TRUST_PROXY_HEADERS: bool = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in ("1", "true", "yes") diff --git a/src/fabledassistant/services/calendar_sync.py b/src/fabledassistant/services/calendar_sync.py index 2b7c781..fc64685 100644 --- a/src/fabledassistant/services/calendar_sync.py +++ b/src/fabledassistant/services/calendar_sync.py @@ -66,7 +66,7 @@ async def setup_user_calendar(user_id: int) -> bool: - Fabled Assistant + Fabled Scribe """, diff --git a/src/fabledassistant/services/email.py b/src/fabledassistant/services/email.py index 946d76d..53d6362 100644 --- a/src/fabledassistant/services/email.py +++ b/src/fabledassistant/services/email.py @@ -38,7 +38,7 @@ _EMAIL_LOGO_SVG = ( def _email_html(title: str, body: str) -> str: - """Wrap email body content in the standard Fabled Assistant template.""" + """Wrap email body content in the standard Fabled Scribe template.""" return f""" @@ -56,7 +56,7 @@ def _email_html(title: str, body: str) -> str:
- {_EMAIL_LOGO_SVG}Fabled Assistant + {_EMAIL_LOGO_SVG}Fabled Scribe

{title}

@@ -68,7 +68,7 @@ def _email_html(title: str, body: str) -> str:
-

Sent by your Fabled Assistant instance.

+

Sent by your Fabled Scribe instance.

@@ -146,7 +146,7 @@ async def send_email(to: str, subject: str, html_body: str) -> None: username = config.get("smtp_username", "") password = config.get("smtp_password", "") from_address = config.get("smtp_from_address", "") - from_name = config.get("smtp_from_name", "Fabled Assistant") + from_name = config.get("smtp_from_name", "Fabled Scribe") use_tls = config.get("smtp_use_tls", "true") == "true" msg = EmailMessage() @@ -176,6 +176,6 @@ async def send_test_email(to: str) -> None: """Send a branded test email.""" body = """

SMTP is configured correctly

-

Your Fabled Assistant instance can send email notifications.

+

Your Fabled Scribe instance can send email notifications.

""" - await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body)) + await send_email(to, "Fabled Scribe - Test Email", _email_html("Test Email", body)) diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 676aab0..ba8060a 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -632,7 +632,7 @@ async def build_context( tool_guidance = "\n".join(tool_lines) static_block = ( - f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. " + f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Scribe. " "Help users with their notes, tasks, and general questions. " "When note context is provided, use it to give relevant answers.\n\n" f"{tool_guidance}" diff --git a/src/fabledassistant/services/notifications.py b/src/fabledassistant/services/notifications.py index a6c4b71..2ec1cb6 100644 --- a/src/fabledassistant/services/notifications.py +++ b/src/fabledassistant/services/notifications.py @@ -91,7 +91,7 @@ async def notify_security_event(

If this wasn't you, change your password immediately.

""" - await send_email(email, f"Fabled Assistant - {label}", _email_html("Security Alert", body)) + await send_email(email, f"Fabled Scribe - {label}", _email_html("Security Alert", body)) except Exception: logger.exception("Failed to send security notification for user %d", user_id) @@ -113,7 +113,7 @@ async def send_password_reset_email(email: str, reset_url: str) -> None:

If the button doesn't work, copy and paste this link:

{reset_url}

""" - await send_email(email, "Fabled Assistant - Password Reset", _email_html("Password Reset", body)) + await send_email(email, "Fabled Scribe - Password Reset", _email_html("Password Reset", body)) async def send_password_reset_success_email(email: str) -> None: @@ -125,14 +125,14 @@ async def send_password_reset_success_email(email: str) -> None:

If you didn't make this change, please contact your administrator immediately.

""" - await send_email(email, "Fabled Assistant - Password Changed", _email_html("Password Changed", body)) + await send_email(email, "Fabled Scribe - Password Changed", _email_html("Password Changed", body)) async def send_invitation_email(email: str, invite_url: str, invited_by_username: str) -> None: """Send a branded invitation email with a registration link.""" body = f"""

- {invited_by_username} has invited you to join Fabled Assistant. + {invited_by_username} has invited you to join Fabled Scribe. Click the button below to create your account.

@@ -146,7 +146,7 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username

If the button doesn't work, copy and paste this link:

{invite_url}

""" - await send_email(email, "Fabled Assistant - You're Invited!", _email_html("You're Invited!", body)) + await send_email(email, "Fabled Scribe - You're Invited!", _email_html("You're Invited!", body)) async def check_due_tasks() -> None: @@ -225,7 +225,7 @@ async def check_due_tasks() -> None: {task_rows} """ - await send_email(email, f"Fabled Assistant - {len(user_tasks)} Task(s) Due", _email_html("Task Reminders", body)) + await send_email(email, f"Fabled Scribe - {len(user_tasks)} Task(s) Due", _email_html("Task Reminders", body)) await log_audit( "task_reminder", user_id=user_id, From b20d6dec66c76468bb1a71092036536d81c9e09e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Apr 2026 17:56:02 -0400 Subject: [PATCH 02/23] chore(scripts): make pre_commit_fable_mcp.sh location-independent Derive REPO_ROOT from the script's own location instead of hardcoding the absolute project path, so the hook keeps working if the project directory is renamed or moved. Co-Authored-By: Claude Opus 4.7 --- scripts/pre_commit_fable_mcp.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/pre_commit_fable_mcp.sh b/scripts/pre_commit_fable_mcp.sh index 966b90e..8dbd54c 100755 --- a/scripts/pre_commit_fable_mcp.sh +++ b/scripts/pre_commit_fable_mcp.sh @@ -8,7 +8,7 @@ set -euo pipefail -REPO_ROOT="/home/bvandeusen/Nextcloud/Projects/fabledassistant" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" input=$(cat) command=$(echo "$input" | python3 -c " From 0fe71419492449e257f67ab4dae67b87d2526a87 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Apr 2026 20:35:37 -0400 Subject: [PATCH 03/23] =?UTF-8?q?feat(db):=20migration=200040=20=E2=80=94?= =?UTF-8?q?=20rename=20briefing=5Fdate=20to=20day=5Fdate,=20hard=20cut=20b?= =?UTF-8?q?riefing=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- ...rename_briefing_date_drop_briefing_data.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 alembic/versions/0040_rename_briefing_date_drop_briefing_data.py diff --git a/alembic/versions/0040_rename_briefing_date_drop_briefing_data.py b/alembic/versions/0040_rename_briefing_date_drop_briefing_data.py new file mode 100644 index 0000000..25ec187 --- /dev/null +++ b/alembic/versions/0040_rename_briefing_date_drop_briefing_data.py @@ -0,0 +1,26 @@ +"""rename briefing_date to day_date and drop existing briefing conversations + +Revision ID: 0040 +Revises: 0039 +Create Date: 2026-04-25 + +This is a hard-cut migration accompanying the Journal feature. The user +has accepted destruction of existing briefing data per the design spec. +""" +from alembic import op + + +revision = "0040" +down_revision = "0039" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("DELETE FROM conversations WHERE conversation_type = 'briefing'") + op.alter_column("conversations", "briefing_date", new_column_name="day_date") + op.execute("DELETE FROM settings WHERE key = 'briefing_config'") + + +def downgrade() -> None: + op.alter_column("conversations", "day_date", new_column_name="briefing_date") From 6752765e2b626404b2365eea01f0de3d3dcd5ac3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Apr 2026 20:38:37 -0400 Subject: [PATCH 04/23] =?UTF-8?q?feat(db):=20migration=200041=20=E2=80=94?= =?UTF-8?q?=20add=20moments=20+=20four=20junction=20tables=20(FK=20notes)?= =?UTF-8?q?=20+=20embeddings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- alembic/versions/0041_add_moments.py | 128 +++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 alembic/versions/0041_add_moments.py diff --git a/alembic/versions/0041_add_moments.py b/alembic/versions/0041_add_moments.py new file mode 100644 index 0000000..c1a9f6e --- /dev/null +++ b/alembic/versions/0041_add_moments.py @@ -0,0 +1,128 @@ +"""add moments + junction tables + moment embeddings + +Revision ID: 0041 +Revises: 0040 +Create Date: 2026-04-25 + +People, Places, Tasks, and Notes all live in the `notes` table (distinguished +by note_type and is_task). The four junction tables FK to notes(id) but stay +separate so per-link-kind queries don't require a discriminator filter. +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import ARRAY, JSONB + + +revision = "0041" +down_revision = "0040" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "moments", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column( + "user_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "conversation_id", + sa.Integer, + sa.ForeignKey("conversations.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "source_message_id", + sa.Integer, + sa.ForeignKey("messages.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("day_date", sa.Date, nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "recorded_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("content", sa.Text, nullable=False), + sa.Column("raw_excerpt", sa.Text, nullable=True), + sa.Column( + "tags", + ARRAY(sa.Text), + nullable=False, + server_default=sa.text("'{}'::text[]"), + ), + sa.Column( + "pinned", + sa.Boolean, + nullable=False, + server_default=sa.text("false"), + ), + ) + op.create_index("ix_moments_user_day", "moments", ["user_id", "day_date"]) + op.create_index("ix_moments_user_occurred", "moments", ["user_id", "occurred_at"]) + + # Four junction tables, all FK to notes(id). Separate (vs. one merged + # table with a discriminator) so per-kind queries don't need a filter. + for table_name, fk_col in [ + ("moment_people", "person_id"), + ("moment_places", "place_id"), + ("moment_tasks", "task_id"), + ("moment_notes", "note_id"), + ]: + op.create_table( + table_name, + sa.Column( + "moment_id", + sa.Integer, + sa.ForeignKey("moments.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + fk_col, + sa.Integer, + sa.ForeignKey("notes.id", ondelete="CASCADE"), + primary_key=True, + ), + ) + op.create_index(f"ix_{table_name}_{fk_col}", table_name, [fk_col]) + + op.create_table( + "moment_embeddings", + sa.Column( + "moment_id", + sa.Integer, + sa.ForeignKey("moments.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column("user_id", sa.Integer, nullable=False), + sa.Column("embedding", JSONB, nullable=False), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + ) + op.create_index("ix_moment_embeddings_user", "moment_embeddings", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_moment_embeddings_user", table_name="moment_embeddings") + op.drop_table("moment_embeddings") + for table_name, fk_col in [ + ("moment_notes", "note_id"), + ("moment_tasks", "task_id"), + ("moment_places", "place_id"), + ("moment_people", "person_id"), + ]: + op.drop_index(f"ix_{table_name}_{fk_col}", table_name=table_name) + op.drop_table(table_name) + op.drop_index("ix_moments_user_occurred", table_name="moments") + op.drop_index("ix_moments_user_day", table_name="moments") + op.drop_table("moments") From d352e9264b0d3a33e17b9b436d33e6afd48ed5cc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Apr 2026 20:40:02 -0400 Subject: [PATCH 05/23] =?UTF-8?q?feat(models):=20Moment=20+=20MomentEmbedd?= =?UTF-8?q?ing=20+=20junctions;=20rename=20Conversation.briefing=5Fdate=20?= =?UTF-8?q?=E2=86=92=20day=5Fdate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also rename services/tz.user_briefing_date → user_day_date with a backwards compat alias (briefing modules using the old name will be deleted in the upcoming briefing tear-down stage). Update services/chat.py to_dict to use day_date. Co-Authored-By: Claude Opus 4.7 --- src/fabledassistant/models/__init__.py | 8 ++ src/fabledassistant/models/conversation.py | 10 +- src/fabledassistant/models/moment.py | 130 +++++++++++++++++++++ src/fabledassistant/services/chat.py | 2 +- src/fabledassistant/services/tz.py | 29 +++-- 5 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 src/fabledassistant/models/moment.py diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py index f380e0d..2eda686 100644 --- a/src/fabledassistant/models/__init__.py +++ b/src/fabledassistant/models/__init__.py @@ -43,3 +43,11 @@ from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F40 from fabledassistant.models.api_key import ApiKey # noqa: E402, F401 from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401 from fabledassistant.models.rss_item_embedding import RssItemEmbedding # noqa: E402, F401 +from fabledassistant.models.moment import ( # noqa: E402, F401 + Moment, + MomentEmbedding, + moment_people, + moment_places, + moment_tasks, + moment_notes, +) diff --git a/src/fabledassistant/models/conversation.py b/src/fabledassistant/models/conversation.py index a09a3e1..8c4bd0f 100644 --- a/src/fabledassistant/models/conversation.py +++ b/src/fabledassistant/models/conversation.py @@ -18,11 +18,11 @@ class Conversation(Base, TimestampMixin): ) title: Mapped[str] = mapped_column(Text, default="") model: Mapped[str] = mapped_column(Text, default="") - # 'chat' (default) or 'briefing'. Briefing conversations are hidden from - # the main chat view and managed by the briefing pipeline. + # 'chat' (default) or 'journal'. Journal conversations are day-anchored + # and rendered by the /journal view; they are hidden from the main chat list. conversation_type: Mapped[str] = mapped_column(Text, default="chat", server_default="chat") - # For briefing conversations only: the calendar date this briefing covers. - briefing_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True) + # For journal conversations only: the calendar date this conversation covers. + day_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True) # NULL = orphan notes only; -1 = all notes; positive int = specific project rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) @@ -48,7 +48,7 @@ class Conversation(Base, TimestampMixin): "title": self.title, "model": self.model, "conversation_type": self.conversation_type, - "briefing_date": self.briefing_date.isoformat() if self.briefing_date else None, + "day_date": self.day_date.isoformat() if self.day_date else None, "rag_project_id": self.rag_project_id, "message_count": msg_count, "created_at": self.created_at.isoformat(), diff --git a/src/fabledassistant/models/moment.py b/src/fabledassistant/models/moment.py new file mode 100644 index 0000000..e2d8cb9 --- /dev/null +++ b/src/fabledassistant/models/moment.py @@ -0,0 +1,130 @@ +import datetime + +from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, Table, Text +from sqlalchemy.dialects.postgresql import ARRAY, JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from fabledassistant.models import Base + + +# Junction tables. People, Places, Tasks, and (regular) Notes all live in +# the `notes` table — the four junctions are kept separate (rather than one +# merged table with a discriminator) so per-kind queries don't require a +# filter, and so the schema is explicit about which links are which. +moment_people = Table( + "moment_people", + Base.metadata, + Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True), + Column("person_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), +) + +moment_places = Table( + "moment_places", + Base.metadata, + Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True), + Column("place_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), +) + +moment_tasks = Table( + "moment_tasks", + Base.metadata, + Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True), + Column("task_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), +) + +moment_notes = Table( + "moment_notes", + Base.metadata, + Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True), + Column("note_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), +) + + +class Moment(Base): + """A small structured extraction from a journal conversation. + + Many per day. Emitted by the LLM via the `record_moment` tool when it + notices a meaningful beat. Stored separately from Notes — they are + different in kind: Notes are curated artifacts; Moments are ambient + trace data with their own embedding index, so notes-RAG and journal-RAG + cannot cross-contaminate. + """ + + __tablename__ = "moments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + conversation_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("conversations.id", ondelete="SET NULL"), nullable=True + ) + source_message_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("messages.id", ondelete="SET NULL"), nullable=True + ) + day_date: Mapped[datetime.date] = mapped_column(Date, nullable=False) + occurred_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + recorded_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.datetime.now(datetime.timezone.utc), + ) + content: Mapped[str] = mapped_column(Text, nullable=False) + raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True) + tags: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=list) + pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + people = relationship("Note", secondary=moment_people, lazy="selectin", viewonly=True) + places = relationship("Note", secondary=moment_places, lazy="selectin", viewonly=True) + tasks = relationship("Note", secondary=moment_tasks, lazy="selectin", viewonly=True) + notes = relationship("Note", secondary=moment_notes, lazy="selectin", viewonly=True) + + __table_args__ = ( + Index("ix_moments_user_day", "user_id", "day_date"), + Index("ix_moments_user_occurred", "user_id", "occurred_at"), + ) + + def to_dict(self, *, include_links: bool = True) -> dict: + result: dict = { + "id": self.id, + "user_id": self.user_id, + "conversation_id": self.conversation_id, + "source_message_id": self.source_message_id, + "day_date": self.day_date.isoformat(), + "occurred_at": self.occurred_at.isoformat(), + "recorded_at": self.recorded_at.isoformat(), + "content": self.content, + "raw_excerpt": self.raw_excerpt, + "tags": list(self.tags or []), + "pinned": self.pinned, + } + if include_links: + result["people"] = [{"id": p.id, "title": p.title} for p in (self.people or [])] + result["places"] = [{"id": p.id, "title": p.title} for p in (self.places or [])] + result["task_ids"] = [t.id for t in (self.tasks or [])] + result["note_ids"] = [n.id for n in (self.notes or [])] + return result + + +class MomentEmbedding(Base): + """Embedding vector for a Moment — used by `search_journal` semantic mode. + + Stored separately from `note_embeddings` so notes-RAG and journal-RAG + cannot cross-contaminate. This is a hard invariant of the journal design. + """ + + __tablename__ = "moment_embeddings" + + moment_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("moments.id", ondelete="CASCADE"), + primary_key=True, + ) + user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + embedding: Mapped[list] = mapped_column(JSONB, nullable=False) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + ) diff --git a/src/fabledassistant/services/chat.py b/src/fabledassistant/services/chat.py index e3052b3..2adabad 100644 --- a/src/fabledassistant/services/chat.py +++ b/src/fabledassistant/services/chat.py @@ -80,7 +80,7 @@ async def list_conversations( "title": conv.title, "model": conv.model, "conversation_type": conv.conversation_type, - "briefing_date": conv.briefing_date.isoformat() if conv.briefing_date else None, + "day_date": conv.day_date.isoformat() if conv.day_date else None, "rag_project_id": conv.rag_project_id, "message_count": row[1], "created_at": conv.created_at.isoformat(), diff --git a/src/fabledassistant/services/tz.py b/src/fabledassistant/services/tz.py index b7b23b1..fb73bf3 100644 --- a/src/fabledassistant/services/tz.py +++ b/src/fabledassistant/services/tz.py @@ -13,11 +13,13 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fabledassistant.services.settings import get_setting -# Briefing day boundary — kept in sync with the compilation slot in -# ``briefing_scheduler.SLOTS``. The briefing day flips at this local hour -# (not midnight) so the 00:00–04:00 local window still shows yesterday's -# briefing until the 4am compilation generates the new one. -BRIEFING_DAY_START_HOUR = 4 +# Day-rollover boundary for journal/day-anchored views. The "day" flips at +# this local hour (not midnight) so the 00:00–04:00 local window still +# shows yesterday's content until the user "starts" the new day. +DAY_ROLLOVER_HOUR = 4 + +# Backwards-compat alias for code paths still referencing the old name. +BRIEFING_DAY_START_HOUR = DAY_ROLLOVER_HOUR async def get_user_tz(user_id: int) -> ZoneInfo: @@ -35,13 +37,16 @@ async def user_today(user_id: int) -> date: return datetime.now(tz).date() -async def user_briefing_date(user_id: int) -> date: - """Return the current "briefing day" in the user's local timezone. +async def user_day_date(user_id: int) -> date: + """Return the current "day" in the user's local timezone. - The briefing day flips at ``BRIEFING_DAY_START_HOUR`` (4am local), - aligned with the compilation slot that generates the day's briefing. - Between 00:00 and 04:00 local this still returns *yesterday*, so the - UI keeps showing the in-progress briefing until the new one is built. + The day flips at ``DAY_ROLLOVER_HOUR`` (4am local) rather than midnight, + so the 00:00–04:00 local window still returns *yesterday* — the user's + journal stays anchored to yesterday until they cross the rollover. """ tz = await get_user_tz(user_id) - return (datetime.now(tz) - timedelta(hours=BRIEFING_DAY_START_HOUR)).date() + return (datetime.now(tz) - timedelta(hours=DAY_ROLLOVER_HOUR)).date() + + +# Backwards-compat alias used by briefing-only modules being torn down in Stage B. +user_briefing_date = user_day_date From 7602bf22933aa91677850f8391400d907ae6f34d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Apr 2026 22:33:37 -0400 Subject: [PATCH 06/23] feat(briefing): hard-cut tear-down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Delete briefing services (pipeline, scheduler, conversations, profile, tools) - Delete routes/briefing.py + remove blueprint registration - Move _get_temp_unit into services/weather.get_temp_unit (reads top-level temp_unit setting) - Rename briefing_preferences.py → rss_filtering.py (functions are RSS-specific) - Strip briefing scheduler hooks from app.py - Strip briefing scheduler call from routes/settings.py - Update test imports (test_rss_service, test_tz_helpers) Frontend: - Delete BriefingView, BriefingSetupWizard, BriefingToolStatusRow - Strip /briefing route + nav links (AppHeader, KnowledgeView) - Strip Settings → Briefing tab + state + functions + imports - Strip briefing-intermediate handling from ChatMessage - Hide /news route + nav links (NewsView depended on briefing endpoints; orphaned in tree) - Drop unused useSettingsStore from AppHeader The Android BriefingScreen lives in a separate repo and is not touched here. Co-Authored-By: Claude Opus 4.7 --- frontend/src/components/AppHeader.vue | 6 - .../src/components/BriefingSetupWizard.vue | 413 --------- .../src/components/BriefingToolStatusRow.vue | 168 ---- frontend/src/components/ChatMessage.vue | 74 +- frontend/src/router/index.ts | 10 - frontend/src/views/BriefingView.vue | 828 ------------------ frontend/src/views/KnowledgeView.vue | 1 - frontend/src/views/SettingsView.vue | 369 +------- src/fabledassistant/app.py | 8 +- src/fabledassistant/routes/briefing.py | 705 --------------- src/fabledassistant/routes/settings.py | 13 +- .../services/briefing_conversations.py | 92 -- .../services/briefing_pipeline.py | 429 --------- .../services/briefing_profile.py | 80 -- .../services/briefing_scheduler.py | 625 ------------- .../services/briefing_tools.py | 9 - ...iefing_preferences.py => rss_filtering.py} | 0 src/fabledassistant/services/tools/weather.py | 6 +- src/fabledassistant/services/weather.py | 7 + tests/test_briefing_models.py | 40 - tests/test_briefing_pipeline.py | 39 - tests/test_briefing_scheduler.py | 114 --- tests/test_rss_service.py | 6 +- tests/test_tz_helpers.py | 12 +- 24 files changed, 28 insertions(+), 4026 deletions(-) delete mode 100644 frontend/src/components/BriefingSetupWizard.vue delete mode 100644 frontend/src/components/BriefingToolStatusRow.vue delete mode 100644 frontend/src/views/BriefingView.vue delete mode 100644 src/fabledassistant/routes/briefing.py delete mode 100644 src/fabledassistant/services/briefing_conversations.py delete mode 100644 src/fabledassistant/services/briefing_pipeline.py delete mode 100644 src/fabledassistant/services/briefing_profile.py delete mode 100644 src/fabledassistant/services/briefing_scheduler.py delete mode 100644 src/fabledassistant/services/briefing_tools.py rename src/fabledassistant/services/{briefing_preferences.py => rss_filtering.py} (100%) delete mode 100644 tests/test_briefing_models.py delete mode 100644 tests/test_briefing_pipeline.py delete mode 100644 tests/test_briefing_scheduler.py diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index d366490..07fad8a 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -5,7 +5,6 @@ import { useTheme } from "@/composables/useTheme"; import { useShortcuts } from "@/composables/useShortcuts"; import { useAuthStore } from "@/stores/auth"; import { useChatStore } from "@/stores/chat"; -import { useSettingsStore } from "@/stores/settings"; import AppLogo from "@/components/AppLogo.vue"; import NotificationBell from "@/components/NotificationBell.vue"; @@ -13,7 +12,6 @@ const { theme, toggleTheme } = useTheme(); const { toggleShortcuts } = useShortcuts(); const authStore = useAuthStore(); const chatStore = useChatStore(); -const settingsStore = useSettingsStore(); const router = useRouter(); const route = useRoute(); @@ -78,9 +76,7 @@ router.afterEach(() => {
@@ -127,10 +123,8 @@ router.afterEach(() => {
Knowledge Chat - Briefing Calendar Projects - News Shared
Settings diff --git a/frontend/src/components/BriefingSetupWizard.vue b/frontend/src/components/BriefingSetupWizard.vue deleted file mode 100644 index 6f32449..0000000 --- a/frontend/src/components/BriefingSetupWizard.vue +++ /dev/null @@ -1,413 +0,0 @@ - - - - - diff --git a/frontend/src/components/BriefingToolStatusRow.vue b/frontend/src/components/BriefingToolStatusRow.vue deleted file mode 100644 index 7ba64c4..0000000 --- a/frontend/src/components/BriefingToolStatusRow.vue +++ /dev/null @@ -1,168 +0,0 @@ - - - - - diff --git a/frontend/src/components/ChatMessage.vue b/frontend/src/components/ChatMessage.vue index ebb6f67..cec152a 100644 --- a/frontend/src/components/ChatMessage.vue +++ b/frontend/src/components/ChatMessage.vue @@ -3,16 +3,8 @@ import { computed } from "vue"; import { renderMarkdown } from "@/utils/markdown"; import { useSettingsStore } from "@/stores/settings"; import ToolCallCard from "@/components/ToolCallCard.vue"; -import BriefingToolStatusRow from "@/components/BriefingToolStatusRow.vue"; import type { Message } from "@/types/chat"; -const SLOT_LABELS: Record = { - compilation: "Full Briefing", - morning: "Morning Update", - midday: "Midday Update", - afternoon: "Afternoon Update", -}; - const settingsStore = useSettingsStore(); const props = defineProps<{ @@ -50,28 +42,7 @@ function formatMs(ms: number | null | undefined): string { } const metadata = computed(() => (props.message.metadata ?? {}) as Record); - -const isBriefingIntermediate = computed( - () => props.message.role === "assistant" && metadata.value.briefing_intermediate === true, -); - -const briefingSlot = computed(() => { - const slot = metadata.value.briefing_slot; - return typeof slot === "string" ? slot : null; -}); - -const slotLabel = computed(() => { - const slot = briefingSlot.value; - if (!slot) return null; - return SLOT_LABELS[slot] ?? slot; -}); - -const isSlotUpdate = computed( - () => - !isBriefingIntermediate.value && - briefingSlot.value != null && - briefingSlot.value !== "compilation", -); +void metadata; const timingParts = computed((): string[] => { const t = props.message.timing; @@ -89,16 +60,11 @@ const timingParts = computed((): string[] => {