diff --git a/Dockerfile b/Dockerfile index d2f5e0e..c5d5342 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:22-alpine AS build-frontend WORKDIR /build COPY frontend/package.json frontend/package-lock.json* ./ -RUN npm install -g npm@latest --quiet && npm install +RUN npm ci --quiet COPY frontend/ . RUN npm run build diff --git a/fable-mcp/fable_mcp/server.py b/fable-mcp/fable_mcp/server.py index bf0a361..87ad547 100644 --- a/fable-mcp/fable_mcp/server.py +++ b/fable-mcp/fable_mcp/server.py @@ -9,7 +9,76 @@ from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, ad load_dotenv() -mcp = FastMCP("fable") +_INSTRUCTIONS = """ +Fable Assistant is a self-hosted second-brain and project management system with LLM integration. + +## Data model + +The hierarchy is: Project → Milestone → Task/Note. + +- **Notes** and **Tasks** share the same underlying model. Tasks are notes with `is_task=True`. + The note tools (fable_*_note) operate on notes; the task tools (fable_*_task) operate on tasks. + Do not use note tools to manipulate tasks or vice versa. + +- **Projects** group related work. A project has a title, description, goal, status, and an + auto-generated summary used for semantic search. Status values: `active`, `archived`. + +- **Milestones** belong to a project and group tasks within it. Status values: `active`, `done`. + +- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`. + - Status values: `todo`, `in_progress`, `done`, `cancelled` + - Priority values: `low`, `normal`, `high` + +- **Notes** are free-form markdown documents. They can belong to a project or be standalone + (orphan notes). Orphan notes are included in the default RAG scope for chat conversations. + +## Tags + +Tags are plain strings — do NOT include a `#` prefix. Example: `["python", "architecture"]`. +Tags are stored as an array on the note/task. Passing `tags=[]` clears all tags; omitting `tags` +leaves existing tags unchanged on updates. + +## Integer-or-none fields + +Due to MCP type constraints, optional integer fields (project_id, milestone_id, parent_id) +use `0` to mean "not set / no association". Pass `0` to leave the field unset. + +## Search + +`fable_search` performs semantic (embedding-based) search over notes and tasks. Use it to find +relevant content by meaning rather than exact keywords. Returns results ranked by cosine +similarity with id, title, a body snippet, and tags. + +## Chat / LLM delegation + +`fable_send_message` sends a natural-language message to Fable's built-in LLM (Ollama). Fable +handles its own tool use, RAG context injection, and conversation history internally. + +Use `fable_send_message` when: +- The request is conversational or requires Fable's internal reasoning across many records +- You want Fable's RAG to surface relevant notes automatically + +Use the direct CRUD tools when: +- You know exactly what to create/read/update/delete +- You need structured data back (IDs, field values) for further processing +- You are populating Fable programmatically from another system + +## Task logs + +Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting +its main body. Suitable for recording work sessions, decisions, or status updates over time. + +## RSS / Briefing + +Fable runs a daily briefing that summarises tasks, calendar events, and RSS feed items. +Use `fable_add_rss_feed` / `fable_remove_rss_feed` to manage the feeds included in that briefing. + +## Admin logs + +`fable_get_app_logs` requires an admin-scoped API key. Regular user keys will be rejected. +""" + +mcp = FastMCP("fable", instructions=_INSTRUCTIONS) # --------------------------------------------------------------------------- @@ -24,7 +93,13 @@ async def fable_list_notes( tag: str = "", search_text: str = "", ) -> dict: - """List notes stored in Fable. Optionally filter by tag or search text.""" + """List notes (non-task documents) stored in Fable. + + Optionally filter by a single tag (plain string, no # prefix) or a keyword search + against title and body. Results are ordered by last-updated descending. + + Use fable_search for semantic/meaning-based lookup instead of exact keyword search. + """ async with FableClient() as client: return await notes.list_notes( client, @@ -37,7 +112,10 @@ async def fable_list_notes( @mcp.tool() async def fable_get_note(note_id: int) -> dict: - """Fetch the full content of a single Fable note by its ID.""" + """Fetch the full content of a single Fable note by its ID. + + Returns id, title, body (markdown), tags, project_id, created_at, updated_at. + """ async with FableClient() as client: return await notes.get_note(client, note_id=note_id) @@ -49,7 +127,16 @@ async def fable_create_note( tags: list[str] | None = None, project_id: int = 0, ) -> dict: - """Create a new note in Fable.""" + """Create a new note in Fable. + + Args: + title: Note title (required). + body: Markdown content. Supports [[wikilinks]] to other notes by title. + tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"]. + project_id: Associate with a project (use 0 for no project / orphan note). + + Returns the created note object including its assigned id. + """ async with FableClient() as client: return await notes.create_note( client, @@ -68,7 +155,15 @@ async def fable_update_note( tags: list[str] | None = None, project_id: int = 0, ) -> dict: - """Update an existing Fable note. Only provided fields are changed.""" + """Update an existing Fable note. Only explicitly provided fields are changed. + + Args: + note_id: ID of the note to update. + title: New title, or omit to leave unchanged. + body: New markdown body, or omit to leave unchanged. + tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged. + project_id: New project association (0 = remove from project). Omit to leave unchanged. + """ async with FableClient() as client: return await notes.update_note( client, @@ -82,7 +177,7 @@ async def fable_update_note( @mcp.tool() async def fable_delete_note(note_id: int) -> str: - """Delete a Fable note by ID.""" + """Permanently delete a Fable note by ID. This cannot be undone.""" async with FableClient() as client: await notes.delete_note(client, note_id=note_id) return f"Note {note_id} deleted." @@ -100,7 +195,14 @@ async def fable_list_tasks( status: str = "", project_id: int = 0, ) -> dict: - """List tasks in Fable. Filter by status (todo/in_progress/done) or project.""" + """List tasks in Fable. + + Args: + status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all. + project_id: Filter to a specific project. Use 0 for no filter. + + Results are ordered by last-updated descending. + """ async with FableClient() as client: return await tasks.list_tasks( client, @@ -113,7 +215,11 @@ async def fable_list_tasks( @mcp.tool() async def fable_get_task(task_id: int) -> dict: - """Fetch a single Fable task by ID, including parent task title.""" + """Fetch a single Fable task by ID. + + Returns id, title, body, status, priority, tags, project_id, milestone_id, + parent_id, parent_title, due_date, created_at, updated_at. + """ async with FableClient() as client: return await tasks.get_task(client, task_id=task_id) @@ -129,7 +235,20 @@ async def fable_create_task( parent_id: int = 0, tags: list[str] | None = None, ) -> dict: - """Create a new task in Fable.""" + """Create a new task in Fable. + + Args: + title: Task title (required). + body: Markdown description / notes for the task. + status: Initial status — one of: todo (default), in_progress, done, cancelled. + priority: One of: low, normal, high. Omit for no priority. + project_id: Associate with a project (0 = no project). + milestone_id: Place within a project milestone (0 = no milestone). + parent_id: Make this a sub-task of another task (0 = top-level). + tags: List of plain-string tags without # prefix. + + Returns the created task object including its assigned id. + """ async with FableClient() as client: return await tasks.create_task( client, @@ -154,7 +273,17 @@ async def fable_update_task( project_id: int = 0, milestone_id: int = 0, ) -> dict: - """Update an existing Fable task. Only provided fields are changed.""" + """Update an existing Fable task. Only explicitly provided fields are changed. + + Args: + task_id: ID of the task to update. + title: New title, or omit to leave unchanged. + body: New markdown body, or omit to leave unchanged. + status: New status — one of: todo, in_progress, done, cancelled. + priority: New priority — one of: low, normal, high. + project_id: New project (0 = remove from project). Omit to leave unchanged. + milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged. + """ async with FableClient() as client: return await tasks.update_task( client, @@ -170,7 +299,12 @@ async def fable_update_task( @mcp.tool() async def fable_add_task_log(task_id: int, body: str) -> dict: - """Append a progress log entry to a Fable task.""" + """Append a timestamped progress log entry to a Fable task. + + Use this to record work sessions, decisions, or status updates over time without + overwriting the task's main body. Each entry is stored separately and shown + chronologically in the task view. + """ async with FableClient() as client: return await tasks.add_task_log(client, task_id=task_id, body=body) @@ -182,14 +316,22 @@ async def fable_add_task_log(task_id: int, body: str) -> dict: @mcp.tool() async def fable_list_projects() -> dict: - """List all Fable projects for the current user.""" + """List all Fable projects for the current user. + + Returns id, title, description, goal, status (active/archived), color, + and a short auto-generated summary for each project. + """ async with FableClient() as client: return await projects.list_projects(client) @mcp.tool() async def fable_get_project(project_id: int) -> dict: - """Fetch a Fable project by ID, including milestone summary.""" + """Fetch a Fable project by ID, including its milestone summary. + + Returns full project fields plus a milestone_summary list with each milestone's + id, title, status, and task counts. + """ async with FableClient() as client: return await projects.get_project(client, project_id=project_id) @@ -202,7 +344,17 @@ async def fable_create_project( status: str = "active", color: str = "", ) -> dict: - """Create a new project in Fable.""" + """Create a new project in Fable. + + Args: + title: Project name (required). + description: Short summary of what the project is. + goal: The desired outcome or definition of done for the project. + status: active (default) or archived. + color: Optional hex colour for the project card (e.g. "#6366f1"). + + Returns the created project object including its assigned id. + """ async with FableClient() as client: return await projects.create_project( client, @@ -223,7 +375,16 @@ async def fable_update_project( status: str = "", color: str = "", ) -> dict: - """Update an existing Fable project.""" + """Update an existing Fable project. Only explicitly provided fields are changed. + + Args: + project_id: ID of the project to update. + title: New title, or omit to leave unchanged. + description: New description, or omit to leave unchanged. + goal: New goal/definition-of-done, or omit to leave unchanged. + status: New status — active or archived. + color: New hex colour, or omit to leave unchanged. + """ async with FableClient() as client: return await projects.update_project( client, @@ -243,7 +404,10 @@ async def fable_update_project( @mcp.tool() async def fable_list_milestones(project_id: int) -> dict: - """List milestones for a Fable project.""" + """List milestones for a Fable project, ordered by order_index. + + Returns id, title, description, status (active/done), order_index, and task counts. + """ async with FableClient() as client: return await milestones.list_milestones(client, project_id=project_id) @@ -255,7 +419,16 @@ async def fable_create_milestone( description: str = "", status: str = "active", ) -> dict: - """Create a milestone within a Fable project.""" + """Create a milestone within a Fable project. + + Args: + project_id: The project this milestone belongs to (required). + title: Milestone name (required). + description: Optional description of what this milestone covers. + status: active (default) or done. + + Returns the created milestone including its assigned id. + """ async with FableClient() as client: return await milestones.create_milestone( client, @@ -275,7 +448,16 @@ async def fable_update_milestone( status: str = "", order_index: int = -1, ) -> dict: - """Update a Fable milestone.""" + """Update a Fable milestone. Only explicitly provided fields are changed. + + Args: + project_id: Project the milestone belongs to. + milestone_id: ID of the milestone to update. + title: New title, or omit to leave unchanged. + description: New description, or omit to leave unchanged. + status: New status — active or done. + order_index: New display position (0-based). Use -1 to leave unchanged. + """ async with FableClient() as client: return await milestones.update_milestone( client, @@ -299,10 +481,17 @@ async def fable_search( content_type: str = "all", limit: int = 10, ) -> dict: - """Semantic search over Fable notes and tasks. + """Semantic search over Fable notes and tasks using embedding similarity. - content_type: "note", "task", or "all" (default). - Returns results ranked by similarity with id, title, body snippet, tags. + Finds content by meaning rather than exact keywords. Use this to discover + relevant records when you don't know the exact title or tags. + + Args: + q: Natural-language query string. + content_type: "note", "task", or "all" (default). + limit: Maximum number of results (default 10). + + Returns results ordered by cosine similarity, each with id, title, body snippet, and tags. """ async with FableClient() as client: return await search.search(client, q=q, content_type=content_type, limit=limit) @@ -315,7 +504,11 @@ async def fable_search( @mcp.tool() async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict: - """List MCP chat conversations stored in Fable.""" + """List chat conversations stored in Fable, ordered by last activity. + + Returns id, title, message_count, created_at, updated_at for each conversation. + Use the id with fable_send_message to continue a specific conversation. + """ async with FableClient() as client: return await chat.list_conversations(client, limit=limit, offset=offset) @@ -326,11 +519,28 @@ async def fable_send_message( conversation_id: str = "", think: bool = False, ) -> dict: - """Send a message to Fable's LLM and receive the full response. + """Send a natural-language message to Fable's built-in LLM and receive the full response. - Fable handles tool use, RAG, and history internally. - Pass conversation_id to continue an existing conversation. - Returns conversation_id, response text, and any tool_call events. + Fable handles tool use, RAG context injection, and conversation history internally. + The LLM can create/update notes and tasks, search, manage projects, and more — all + driven by natural language without you needing to call individual tools. + + Use this when: + - The request is conversational or exploratory + - You want Fable's RAG to automatically surface relevant notes as context + - The task is complex enough to benefit from Fable's internal reasoning + + Use the direct CRUD tools (fable_create_note, etc.) instead when you need + structured data back or are performing bulk/programmatic operations. + + Args: + message: The user message to send. + conversation_id: Continue an existing conversation by passing its id. + Omit to start a new conversation. + think: Enable extended reasoning mode for complex multi-step requests. + + Returns conversation_id (for follow-up messages), the assistant response text, + and a list of any tool_call events that fired during generation. """ async with FableClient() as client: return await chat.send_message( @@ -352,10 +562,15 @@ async def fable_get_app_logs( limit: int = 20, search: str = "", ) -> dict: - """Fetch Fable application logs. Requires an admin API key. + """Fetch Fable application logs. Requires an admin-scoped API key. - category: "error" (default) | "audit" | "usage" - search: optional keyword filter applied to action, endpoint, username, and details + Args: + category: Log category — "error" (default), "audit", or "usage". + limit: Maximum number of log entries to return. + search: Optional keyword filter matched against action, endpoint, username, details. + + Returns a list of log entries ordered by most recent first. + Regular user API keys will receive a 403 — only admin keys are accepted. """ async with FableClient() as client: return await admin.get_app_logs( @@ -373,7 +588,11 @@ async def fable_get_app_logs( @mcp.tool() async def fable_list_rss_feeds() -> dict: - """List all RSS feeds configured in Fable for the current user.""" + """List all RSS/Atom feeds configured in Fable for the current user. + + Returns id, title, url, category, and last_fetched_at for each feed. + These feeds are summarised in the user's daily briefing. + """ async with FableClient() as client: return await briefing.list_rss_feeds(client) @@ -384,12 +603,14 @@ async def fable_add_rss_feed( title: str = "", category: str = "", ) -> dict: - """Add a new RSS feed to Fable. Title is optional — auto-populated from feed metadata. + """Add an RSS or Atom feed to Fable's daily briefing. Args: - url: The RSS/Atom feed URL. - title: Optional display name override. - category: Optional category label (e.g. 'news', 'tech'). + url: The RSS/Atom feed URL (required). + title: Optional display name. If omitted, auto-populated from feed metadata. + category: Optional category label to group feeds (e.g. "news", "tech", "finance"). + + Returns the created feed object including its assigned id. """ async with FableClient() as client: return await briefing.add_rss_feed( diff --git a/frontend/src/components/WeatherCard.vue b/frontend/src/components/WeatherCard.vue index 6073e77..e83f604 100644 --- a/frontend/src/components/WeatherCard.vue +++ b/frontend/src/components/WeatherCard.vue @@ -107,34 +107,36 @@ const fetchedAtLabel = computed(() => { display: flex; align-items: baseline; gap: 0.75rem; - margin-bottom: 0.35rem; + margin-bottom: 0.5rem; } .weather-temp { - font-size: 1.8rem; + font-size: 2rem; font-weight: 700; line-height: 1; } .weather-condition { color: var(--color-text-muted); + font-size: 0.9rem; } .weather-today { color: var(--color-text-secondary); margin-bottom: 0.75rem; + font-size: 0.85rem; } .weather-delta { color: var(--color-text-muted); - font-size: 0.85rem; + font-size: 0.82rem; } .weather-forecast { display: flex; - gap: 0.5rem; + gap: 0.75rem; overflow-x: auto; - padding-top: 0.5rem; + padding-top: 0.75rem; border-top: 1px solid var(--color-border); } @@ -142,8 +144,8 @@ const fetchedAtLabel = computed(() => { display: flex; flex-direction: column; align-items: center; - gap: 0.2rem; - min-width: 3.5rem; + gap: 0.25rem; + min-width: 4.5rem; font-size: 0.8rem; } diff --git a/frontend/src/views/BriefingView.vue b/frontend/src/views/BriefingView.vue index e8dee64..0be3cc6 100644 --- a/frontend/src/views/BriefingView.vue +++ b/frontend/src/views/BriefingView.vue @@ -189,9 +189,31 @@ function toMsg(m: BriefingMessage): Message { } } +// ─── Background refresh (no-flicker) ───────────────────────────────────────── +let _refreshTimer: ReturnType | null = null + +async function _backgroundRefreshMessages() { + if (document.hidden || chatStore.streaming || !isToday.value || !todayConvId.value) return + try { + const today = await getBriefingToday() + if (!today) return + const fresh = today.messages + const last = fresh[fresh.length - 1] + const cur = messages.value[messages.value.length - 1] + if (fresh.length !== messages.value.length || last?.content !== cur?.content) { + messages.value = fresh + } + } catch { /* silent — don't disturb the UI on network hiccup */ } +} + onMounted(async () => { await checkSetup() if (!showWizard.value) await loadAll() + _refreshTimer = setInterval(_backgroundRefreshMessages, 60_000) +}) + +onUnmounted(() => { + if (_refreshTimer !== null) clearInterval(_refreshTimer) }) diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 8be4ace..40664c3 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -82,6 +82,43 @@ function milestoneColor(index: number): string { return palette[index % palette.length]; } +// ─── Background refresh (no-flicker) ───────────────────────────────────────── +// Runs on a timer while the page is visible. Never touches `loading` so +// existing content stays on screen while the fetch is in flight. + +let _refreshTimer: ReturnType | null = null + +function _dateRange() { + const today = new Date() + const nextWeek = new Date(today) + nextWeek.setDate(today.getDate() + 7) + return { + todayStr: today.toISOString().slice(0, 10) + 'T00:00:00', + nextWeekStr: nextWeek.toISOString().slice(0, 10) + 'T23:59:59', + } +} + +function _backgroundRefresh() { + if (document.hidden || loading.value) return + const { todayStr, nextWeekStr } = _dateRange() + Promise.allSettled([ + listEvents(todayStr, nextWeekStr), + apiGet('/api/tasks?no_project=true&sort=updated_at&order=desc&limit=8'), + apiGet<{ notes: Note[] }>('/api/notes?type=note&no_project=true&sort=updated_at&order=desc&limit=6'), + ]).then(([eventsRes, tasksRes, notesRes]) => { + if (eventsRes.status === 'fulfilled') upcomingEvents.value = eventsRes.value + if (tasksRes.status === 'fulfilled') orphanTasks.value = tasksRes.value.tasks + if (notesRes.status === 'fulfilled') orphanNotes.value = notesRes.value.notes + }) + if (heroProject.value) { + apiGet( + `/api/tasks?project_id=${heroProject.value.id}&status=todo&sort=updated_at&order=desc&limit=1` + ) + .then((r) => { heroNextUp.value = r.tasks[0] ?? null }) + .catch(() => {}) + } +} + // ─── Data loading ───────────────────────────────────────────────────────────── onMounted(async () => { @@ -135,6 +172,8 @@ onMounted(async () => { // Focus chat input after data loads chatInputRef.value?.focus(); loadProjects(); + + _refreshTimer = setInterval(_backgroundRefresh, 90_000) }); async function loadProjects() { @@ -200,6 +239,7 @@ onMounted(() => { }); onUnmounted(() => { document.removeEventListener("shortcut:focus-chat", onFocusChatShortcut); + if (_refreshTimer !== null) clearInterval(_refreshTimer) }); const chatStore = useChatStore(); diff --git a/src/fabledassistant/services/briefing_pipeline.py b/src/fabledassistant/services/briefing_pipeline.py index bbe68a4..0fa741a 100644 --- a/src/fabledassistant/services/briefing_pipeline.py +++ b/src/fabledassistant/services/briefing_pipeline.py @@ -8,6 +8,7 @@ import asyncio import hashlib import logging from datetime import date, datetime, timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import httpx @@ -129,7 +130,13 @@ async def _gather_internal(user_id: int) -> dict: from fabledassistant.services.projects import list_projects from fabledassistant.services.caldav import is_caldav_configured, list_events - today = date.today().isoformat() + tz_name = await get_setting(user_id, "user_timezone") or "UTC" + try: + user_tz = ZoneInfo(tz_name) + except ZoneInfoNotFoundError: + user_tz = ZoneInfo("UTC") + + today = datetime.now(user_tz).date().isoformat() # Tasks: overdue, due today, high priority in-progress all_tasks: list[dict] = [] @@ -166,9 +173,9 @@ async def _gather_internal(user_id: int) -> dict: calendar_events: list[str] = [] try: from fabledassistant.services.events import list_events as list_internal_events - today_date = date.today() - day_start = datetime(today_date.year, today_date.month, today_date.day, 0, 0, 0) - day_end = datetime(today_date.year, today_date.month, today_date.day, 23, 59, 59) + today_date = datetime.now(user_tz).date() + day_start = datetime(today_date.year, today_date.month, today_date.day, 0, 0, 0, tzinfo=user_tz) + day_end = datetime(today_date.year, today_date.month, today_date.day, 23, 59, 59, tzinfo=user_tz) internal_events = await list_internal_events( user_id=user_id, date_from=day_start, date_to=day_end ) @@ -176,7 +183,8 @@ async def _gather_internal(user_id: int) -> dict: if e.all_day: time_str = "all day" elif e.start_dt: - time_str = e.start_dt.strftime("%-I:%M %p") + local_dt = e.start_dt.astimezone(user_tz) if e.start_dt.tzinfo else e.start_dt.replace(tzinfo=timezone.utc).astimezone(user_tz) + time_str = local_dt.strftime("%-I:%M %p") else: time_str = "unknown time" calendar_events.append(f"{e.title} at {time_str}")