Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2414437061 | |||
| e6f2ee2b94 | |||
| 59dee3a19f | |||
| ce41f2a3ee | |||
| b1226d4e16 | |||
| 37c704e875 | |||
| bb6249e00e | |||
| 9c0308dfee | |||
| 925a53e0f7 | |||
| b65d736869 | |||
| 17211c6e82 | |||
| 90aa1f2fdb | |||
| b519a1c140 | |||
| 257b306a27 | |||
| 8b0878f227 | |||
| 9191ab5b27 | |||
| fd25d2e436 | |||
| 103db883ad | |||
| 5fa203019a | |||
| bda6e6c80f | |||
| 5419330633 | |||
| 362ead7f0d | |||
| 8a3bba4eb8 | |||
| 76dc75a03b | |||
| a551f52682 | |||
| 6de855e226 | |||
| c6357e52d9 | |||
| 5d40f2113f | |||
| 9a96fdb3c0 | |||
| 460959f0d4 | |||
| 0dbbb98cf5 | |||
| 4b7ca1b17e | |||
| 65a3689aaa | |||
| 42c11dedae | |||
| 0fbb1fbd92 | |||
| bb650ba563 | |||
| c663532fd4 | |||
| 090b7d83dd | |||
| 4e9eead3ab | |||
| fc6ebf81eb | |||
| b88d5ee6b3 | |||
| 020bd6614b | |||
| 4403026797 | |||
| 552943d6c0 | |||
| 2576be9e49 | |||
| e17fc088b2 | |||
| c5b0344240 | |||
| c8765959ea | |||
| c33cab7020 | |||
| 36cd08c236 | |||
| f85b92a885 | |||
| 84640a0dc4 | |||
| 4c58603009 | |||
| b7e7073425 | |||
| 94b169f31c | |||
| 2db23cec7a | |||
| b81c4aa600 | |||
| 35fab6cbf7 | |||
| 404698521f | |||
| 9f8b451d15 | |||
| 4f18023284 | |||
| 6c309f1331 | |||
| 03d725ea3e | |||
| 611c940527 | |||
| 3b2a0a119f | |||
| a7de3296b3 | |||
| 88b351a96e | |||
| bc239119f3 | |||
| 30dfbce426 | |||
| 65ba1cc82a | |||
| 4cfea784a9 | |||
| 016a2bd270 | |||
| 39d56cfcf3 | |||
| 3f287d7417 | |||
| 64ab24864a | |||
| 3c1ec4077f | |||
| 541e2ed713 | |||
| ff498ce1a4 | |||
| efb3534f3a | |||
| 4192a64c0f | |||
| f20e54a068 | |||
| de4b1d7c7e | |||
| 3d916d7357 | |||
| df423ab906 | |||
| d023209f13 | |||
| 93a3beb5d6 | |||
| 54710b35e9 | |||
| 7a9a8b7819 |
@@ -1 +1 @@
|
||||
2298268
|
||||
3158517
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""replace events.end_dt with duration_minutes (Fable #160)
|
||||
|
||||
Revision ID: 0043
|
||||
Revises: 0042
|
||||
Create Date: 2026-04-29
|
||||
|
||||
Structural fix for the "end before start" bug class observed on prod
|
||||
2026-04-29: an event landed with end_dt 32 days before start_dt due
|
||||
to a tool-call mishap, then disappeared from upcoming-list filters.
|
||||
Storing duration instead of end_dt makes the invalid state
|
||||
inexpressible at the schema level (duration_minutes >= 0).
|
||||
|
||||
Backfill rules:
|
||||
- end_dt valid (end_dt > start_dt) → duration_minutes = total minutes
|
||||
- end_dt == start_dt → duration_minutes = 0 (zero-duration point)
|
||||
- end_dt NULL OR end_dt < start_dt → duration_minutes = NULL (corrupt
|
||||
or open-ended; treated as a point event from here on)
|
||||
|
||||
Existing API consumers continue to receive `end_dt` in responses — the
|
||||
field is now derived from `start_dt + duration_minutes` in
|
||||
``Event.to_dict()`` rather than stored.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0043"
|
||||
down_revision = "0042"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("duration_minutes", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"events_duration_minutes_non_negative",
|
||||
"events",
|
||||
"duration_minutes IS NULL OR duration_minutes >= 0",
|
||||
)
|
||||
# Backfill: convert valid end_dt into a minute count; leave NULL for
|
||||
# corrupt or absent end_dt. Bad rows (end_dt <= start_dt) collapse
|
||||
# cleanly to point events instead of forcing a recovery guess.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE events
|
||||
SET duration_minutes = CAST(
|
||||
EXTRACT(EPOCH FROM (end_dt - start_dt)) / 60 AS INTEGER
|
||||
)
|
||||
WHERE end_dt IS NOT NULL AND end_dt >= start_dt
|
||||
"""
|
||||
)
|
||||
op.drop_column("events", "end_dt")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"events",
|
||||
sa.Column("end_dt", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# Restore end_dt = start_dt + duration_minutes minutes for rows that
|
||||
# had a duration. NULL duration → NULL end_dt (point event).
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE events
|
||||
SET end_dt = start_dt + (duration_minutes || ' minutes')::interval
|
||||
WHERE duration_minutes IS NOT NULL
|
||||
"""
|
||||
)
|
||||
op.drop_constraint("events_duration_minutes_non_negative", "events")
|
||||
op.drop_column("events", "duration_minutes")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add note.description and note.consolidated_at
|
||||
|
||||
Revision ID: 0044
|
||||
Revises: 0043
|
||||
Create Date: 2026-05-13
|
||||
|
||||
Adds two columns to the ``notes`` table to support the task-as-durable-record
|
||||
design (spec 2026-05-13):
|
||||
|
||||
- ``description``: user-stated goal / initial context. Meaningful when
|
||||
``is_task=true``; left NULL on knowledge notes.
|
||||
- ``consolidated_at``: timestamp of the most recent auto-summary pass. NULL
|
||||
until the first consolidation runs.
|
||||
|
||||
Backfill: for existing tasks we copy ``body`` into ``description`` so the
|
||||
user's original goal text is preserved when ``body`` is later overwritten by
|
||||
the auto-summary pipeline. The brief duplication window between
|
||||
``body`` and ``description`` is harmless and resolves on the first
|
||||
consolidation pass.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0044"
|
||||
down_revision = "0043"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notes", sa.Column("description", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# is_task is a Python property (status IS NOT NULL); there's no DB column
|
||||
# of that name. Backfill description from body for everything that
|
||||
# qualifies as a task at the model layer.
|
||||
op.execute(
|
||||
"UPDATE notes SET description = body "
|
||||
"WHERE status IS NOT NULL AND body IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("notes", "consolidated_at")
|
||||
op.drop_column("notes", "description")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""add pin_kind and pin_label to note_versions
|
||||
|
||||
Revision ID: 0045
|
||||
Revises: 0044
|
||||
Create Date: 2026-05-13
|
||||
|
||||
Two additive columns on note_versions to support tiered retention:
|
||||
|
||||
- pin_kind: NULL (rolling autosave), 'auto' (system-declared via stability
|
||||
scan), 'manual' (user-declared with optional commit-note label).
|
||||
- pin_label: NULL for rolling. Auto-generated for 'auto'; user-supplied
|
||||
for 'manual' (may be NULL).
|
||||
|
||||
No backfill — every existing row stays rolling. The auto-pin scan
|
||||
(services/version_pinning_scheduler.py, daily 03:00 UTC) will catch up on
|
||||
the first scheduled run after deploy.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0045"
|
||||
down_revision = "0044"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("note_versions", sa.Column("pin_kind", sa.Text(), nullable=True))
|
||||
op.add_column("note_versions", sa.Column("pin_label", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("note_versions", "pin_label")
|
||||
op.drop_column("note_versions", "pin_kind")
|
||||
+13
-14
@@ -145,23 +145,22 @@ All endpoints require login (session cookie or `Authorization: Bearer <api-key>`
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/search` | Semantic + keyword search across notes and tasks. Params: `q`, `type` (`note`/`task`/`all`), `limit` |
|
||||
|
||||
## Briefing
|
||||
## Journal
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/briefing/config` | Get briefing configuration |
|
||||
| PUT | `/api/briefing/config` | Save briefing configuration |
|
||||
| GET | `/api/briefing/feeds` | List RSS feeds |
|
||||
| POST | `/api/briefing/feeds` | Add RSS feed `{url, name?, category?}` |
|
||||
| DELETE | `/api/briefing/feeds/:id` | Delete feed |
|
||||
| POST | `/api/briefing/feeds/refresh` | Trigger immediate feed refresh → `{feeds_refreshed, new_items}` |
|
||||
| GET | `/api/briefing/weather` | Get weather configuration |
|
||||
| PUT | `/api/briefing/weather` | Save weather locations |
|
||||
| POST | `/api/briefing/weather/geocode` | Geocode address `{query}` → `{lat, lon, label}` |
|
||||
| POST | `/api/briefing/trigger` | Manually fire a briefing slot `{slot}` |
|
||||
| GET | `/api/briefing/conversations` | List past briefing conversations |
|
||||
| GET | `/api/briefing/conversations/today` | Get/create today's briefing conversation |
|
||||
| GET | `/api/briefing/conversations/:id/messages` | Get messages for a briefing conversation |
|
||||
| GET | `/api/journal/config` | Get journal configuration (locations, temp_unit, prep schedule) |
|
||||
| PUT | `/api/journal/config` | Save journal configuration; live-reschedules the prep job |
|
||||
| GET | `/api/journal/today` | Get/create today's journal conversation + messages |
|
||||
| GET | `/api/journal/day/:iso` | Get a specific day's journal conversation (read-only) |
|
||||
| GET | `/api/journal/days` | List dates with journal content, newest first |
|
||||
| POST | `/api/journal/trigger-prep` | Force-regenerate today's prep (or `{date}` for a specific day) |
|
||||
| GET | `/api/journal/weather` | Cached weather rows; auto-refreshes stale rows in the background |
|
||||
| GET | `/api/journal/weather/current` | Live current conditions for the primary configured location |
|
||||
| POST | `/api/journal/weather/refresh` | Manual refresh of all configured locations |
|
||||
| POST | `/api/journal/weather/geocode` | Geocode place name `{query}` → `{lat, lon, label}` |
|
||||
| POST | `/api/journal/moments/:id/update` | Update a recorded moment |
|
||||
| DELETE | `/api/journal/moments/:id` | Delete a moment |
|
||||
|
||||
## Settings
|
||||
|
||||
|
||||
+13
-14
@@ -133,11 +133,11 @@ Indexes: GIN on `tags`, B-tree on `status`, B-tree on `title`.
|
||||
|
||||
### Settings
|
||||
|
||||
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `briefing_enabled`, `briefing_locations`, `office_days`, etc.
|
||||
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `journal_config` (JSON: locations, temp_unit, prep schedule), `user_timezone`, `voice_*`, etc.
|
||||
|
||||
### Conversations / Messages
|
||||
|
||||
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`briefing`/`mcp`), `briefing_date`, `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
|
||||
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`journal`/`mcp`), `day_date` (YYYY-MM-DD for journal conversations), `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
|
||||
`messages`: `id`, `conversation_id` FK CASCADE, `role` (`user`/`assistant`), `content`, `status` (`done`/`generating`), `created_at`.
|
||||
|
||||
Title auto-generated by LLM on first exchange, re-generated every 10th message.
|
||||
@@ -155,11 +155,9 @@ Title auto-generated by LLM on first exchange, re-generated every 10th message.
|
||||
|
||||
Permission resolution is centralised in `services/access.py`. `get_project_permission(uid, project_id)` checks ownership → direct share → group-based share → note→project inheritance, returning the highest applicable permission.
|
||||
|
||||
### Briefing-Related Tables
|
||||
### Journal-Related Tables
|
||||
|
||||
`rss_feeds`: `id`, `user_id`, `url`, `name`, `category`, `last_fetched_at`.
|
||||
`rss_items`: `id`, `feed_id` FK, `guid`, `title`, `url`, `summary`, `pub_date`.
|
||||
`weather_cache`: per-user cache with `lat`, `lon`, `location_name`, `forecast_json`, `fetched_at`.
|
||||
`weather_cache`: per-user, per-`location_key` cache. Columns: `user_id`, `location_key` (`home`/`work`/etc.), `location_label`, `forecast_json` (Open-Meteo response), `previous_json` (last forecast, used to detect changes), `fetched_at`. Lat/lon are *not* stored on the cache row — they live in the user's `journal_config.locations.{home|work}` setting and are used at refresh time.
|
||||
|
||||
### API Keys
|
||||
|
||||
@@ -188,14 +186,14 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
||||
| `routes/api.py` | `/api` blueprint; `GET /api/health` (public) |
|
||||
| `routes/auth.py` | Register, login, logout, me, password/email change, password reset, invite registration, OAuth login+callback; rate limiting; `LOCAL_AUTH_ENABLED` guards |
|
||||
| `routes/admin.py` | Backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only) |
|
||||
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm; briefing conversation routes |
|
||||
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm |
|
||||
| `routes/notes.py` | Notes CRUD + wikilinks + backlinks + assist + link suggestions + version history + drafts |
|
||||
| `routes/tasks.py` | Tasks CRUD; `POST` accepts `project` name string (resolved to `project_id`) |
|
||||
| `routes/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) |
|
||||
| `routes/projects.py` | Projects CRUD + summary endpoint |
|
||||
| `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` |
|
||||
| `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) |
|
||||
| `routes/briefing.py` | Briefing conversation + reply + history; RSS feed management |
|
||||
| `routes/journal.py` | Journal config CRUD; today/day/days conversation accessors; weather endpoints (cached/current/refresh/geocode); moments CRUD; trigger-prep |
|
||||
| `routes/groups.py` | Group CRUD + membership management (admin) |
|
||||
| `routes/shares.py` | Share project/note with user or group; revoke; list incoming shares |
|
||||
| `routes/in_app_notifications.py` | In-app notification list + mark-read + count |
|
||||
@@ -227,10 +225,11 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
||||
| `services/groups.py` | Group CRUD; membership management |
|
||||
| `services/notifications.py` | Create/read/mark-read in-app notifications |
|
||||
| `services/task_logs.py` | Append/list/delete work log entries on tasks |
|
||||
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → `GenerationBuffer` stream |
|
||||
| `services/briefing_scheduler.py` | APScheduler `BackgroundScheduler`; slots with catch-up logic; async-safe via `asyncio.create_task` |
|
||||
| `services/briefing_conversations.py` | Briefing conversation persistence and history queries |
|
||||
| `services/briefing_profile.py` | Per-user profile note that the assistant updates over time |
|
||||
| `services/journal_prep.py` | Deterministic data gather (tasks/events/weather/projects/recent moments/open threads) → LLM prose opener; persisted as the first assistant message of today's journal Conversation |
|
||||
| `services/journal_pipeline.py` | System-prompt builder for journal conversations; calls `build_profile_context()` so the LLM sees the user's profile + learned summary |
|
||||
| `services/journal_scheduler.py` | APScheduler `BackgroundScheduler`; per-user prep job; live-reschedule via `update_user_schedule()`; catch-up logic for missed runs |
|
||||
| `services/journal_search.py`, `services/moments.py` | Moment recording + search across journal history |
|
||||
| `services/user_profile.py` | `build_profile_context()` consolidates profile + observations + learned_summary into a system-prompt block |
|
||||
| `services/research.py` | SearXNG research pipeline: sub-queries → parallel fetch → outline → section synthesis → executive summary → index note with linked section notes |
|
||||
| `services/events.py` | Internal events CRUD: `list_events`, `create_event`, `update_event`, `delete_event`, `get_event`; source of truth for all event LLM tools |
|
||||
| `routes/events.py` | `/api/events` — event CRUD routes |
|
||||
@@ -293,8 +292,8 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
||||
| `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management |
|
||||
| `services/tools/` | LLM tool implementations (38 tools across 11 modules); decorator-based registry |
|
||||
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` |
|
||||
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → briefing output |
|
||||
| `services/briefing_scheduler.py` | APScheduler integration, catch-up logic for missed slots |
|
||||
| `services/journal_prep.py` | Data gather → LLM prose opener; persisted into today's journal conversation |
|
||||
| `services/journal_scheduler.py` | APScheduler integration, per-user prep job, catch-up for missed runs |
|
||||
| `services/backup.py` | Full and per-user backup export/restore (version 2 format) |
|
||||
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast fetch + DB cache |
|
||||
| `services/rss.py` | feedparser-based fetch, per-feed DB cache, prune-to-100 |
|
||||
|
||||
+52
-2
@@ -362,7 +362,7 @@ If using Tailwind, extend the theme with these tokens rather than relying on def
|
||||
|
||||
> This section tracks decisions made while adapting the FabledSword baseline above for Scribe specifically. Items here are *in progress* — once they feel solid, they get folded into the main body of the document (either as Scribe-specific extensions in the per-app section, or as updates to the universal rules where Scribe's needs reveal a gap in the baseline).
|
||||
|
||||
*Iteration started: 2026-04-26.*
|
||||
*Iteration started: 2026-04-26. Foundation pass shipped 2026-04-27 in `7a9a8b7` (palette, fonts, light mode, action tokens, hardcoded indigo cleanup, warm-gold deprecation). Surface phase shipped 2026-04-27 across `93a3beb` → `3c1ec40` (Lucide migration, Hybrid-rule button reclassification per surface, long-form line-height, two-weights-only). The system is now applied end-to-end; this section will fold into the main body once the result has had time to settle in real use.*
|
||||
|
||||
## Decisions made so far
|
||||
|
||||
@@ -531,4 +531,54 @@ Border weight is not load-bearing for Scribe — happy to use the doc's 0.5px ha
|
||||
|
||||
## Open threads (next iterations)
|
||||
|
||||
*All initial-iteration threads resolved. Next phase is the polish pass — applying the system to existing UI, component by component. New threads will accumulate here as we discover gaps in the polish pass.*
|
||||
### Foundation pass — shipped 2026-04-27 (`7a9a8b7`)
|
||||
|
||||
Mechanical token + font + light-mode rewrite of `frontend/src/assets/theme.css`, plus a sweep of hardcoded indigo and `--color-accent-warm` references across ~14 component files. Action tokens (`--color-action-primary` Moss, `--color-action-secondary` Bronze, `--color-action-destructive` Oxblood, `--color-action-ghost-border` Pewter) are defined but not yet applied — buttons still flow through `--color-primary` and read as dusty-violet gradients in the meantime, by design. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-foundation-design.md` (gitignored, local-only).
|
||||
|
||||
### Surface phase — shipped 2026-04-27 (`93a3beb` → `3c1ec40`)
|
||||
|
||||
Bundled as Hybrid (option C from the brainstorm): Lucide cross-cutting first, then surface-by-surface for the judgment work. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-surface-design.md` (gitignored, local-only). Seven PRs landed on `dev`:
|
||||
|
||||
| PR | Commit | Surface | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | `93a3beb` | Lucide cross-cutting | 60 hand-inlined SVGs across 15 files → `lucide-vue-next`. Every chrome icon at 16 or 24. Emoji-as-icons (`✕`, `✓`, `🎤`, `📎`, `↻`, `↑`, `×`, `☀`/`☾`) swept across the chrome. AppLogo wordmark and the GraphView D3 mount kept as the legitimate exceptions. |
|
||||
| 2 | `3d916d7` | Journal | Buttons audited; `↻` weather refresh → Lucide `RotateCcw`; assistant bubble line-height bumped; redundant `.journal-title` font-family dropped; dead `.news-section` CSS removed. |
|
||||
| 3 | `4192a64` | Chat | `.message-content` long-form 1.7 line-height on assistant bubbles; ToolCallCard outer border removed (bubble already contains it; error state moved to a left-edge accent); bulk-delete recolored to Oxblood with Trash2 icon; `.bulk-link` accent → muted text. |
|
||||
| 4 | `efb3534` | Knowledge cluster | `.prose` line-height bumped to 1.7 globally so all reading surfaces inherit. Save → Moss; Delete → Oxblood + Trash2; Edit / Advance → Moss; Convert / Share → Bronze. |
|
||||
| 5 | `ff498ce` | Project + Workspace | Project save panel → Moss; milestone confirm/cancel → Moss/Bronze; modal-btn-danger and per-row delete affordances → Oxblood; "Open Workspace" stays accent (project-surface brand moment). |
|
||||
| 6 | `541e2ed` | Settings | Densest button surface — every `.btn-save`, `.btn-primary`, `.btn-secondary`, `.btn-danger`, `.btn-danger-outline`, `.btn-toggle-open` reclassified per Hybrid; missing `.btn-danger` style block added (was unstyled before). |
|
||||
| 7 | `3c1ec40` | Edge surfaces | Calendar `New Event` → Moss; EventSlideOver Save/Cancel/Delete reclassified; HomeView hero CTA stays accent (brand moment); two-weights-only sweep across Header/Home/Calendar/Graph. |
|
||||
|
||||
**Cross-cutting changes folded in as the work touched files:**
|
||||
|
||||
- Long-form 1.7 line-height on `.prose` (PR 4) — applies to Note viewer, Task viewer, chat assistant bubbles, anywhere markdown renders into a reading surface.
|
||||
- Two-weights-only (400 + 500) — every `font-weight: 600` and `700` snapped to `500` across all surface PRs.
|
||||
- Hardcoded `--color-danger` in destructive button contexts → `--color-action-destructive` (Oxblood). `--color-danger` (Error terracotta) preserved for validation/error messages, per the doc's distinction between Error and Destructive.
|
||||
- Adjacent `×` / `✕` / unicode-arrow emoji swept opportunistically as files were touched (PRs 5, 7).
|
||||
|
||||
### Out of scope — deferred indefinitely
|
||||
|
||||
Items deliberately not addressed in this round; revisit when a real need surfaces:
|
||||
|
||||
- Lucide stroke-weight overrides (doc spec: 1.5 at 24, 1 at 16; current: Lucide default 2). Touched components if they read too heavy in practice.
|
||||
- Filled-as-active icon state — no current affordance uses it; introduce when bookmark/pin/star toggles are added.
|
||||
- Type-scale / spacing-scale CSS variables — components keep literal values.
|
||||
- Token rename to `--fs-*` namespace — Scribe is the only FabledSword app sharing this codebase.
|
||||
- FabledSword lockup placement — waiting on the actual heraldic mark to be drawn.
|
||||
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
|
||||
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
|
||||
|
||||
### Flutter app port — shipped 2026-04-28
|
||||
|
||||
The companion mobile app (`fabled_app` / FabledApp repo) tracks the same design system. Two commits:
|
||||
|
||||
- **Foundation port** — `0f05f47`. `lib/core/theme.dart` rewritten with the Obsidian/Iron/Pewter dark palette, warm parchment light palette, dusty violet `#5B4A8A` primary. Inter loaded for body, JetBrains Mono available at call sites, Fraunces for headlines ≥18px. New `ActionColors` ThemeExtension exposes Moss/Bronze/Oxblood/Pewter outside the `ColorScheme` (Material's primary/secondary/tertiary slots all carry brand accent, so action tokens need their own home). `GradientButton` recolored to dusty-violet gradient.
|
||||
- **Surface phase** — `b9e68e3`. `lucide_icons ^0.257.0` installed; 107 `Icons.*` references across 21 files swapped to `LucideIcons.*`. Input border radius 24 → 8 in both themes. ChatMessageBubble Illuminated Transcript fixes — neutral border on user bubbles, `surface`/Iron bg on assistant bubbles, asymmetric corner restoration (only bottom-left clipped, not both left corners), accent-tinted glow shadow added. 5 destructive confirm buttons across notes / tasks / chat / calendar wired to `ActionColors.destructive`. Calendar event Save wired to `ActionColors.primary` as the reference Moss site. 4 hardcoded indigo Color literals → dusty-violet equivalents.
|
||||
|
||||
The Flutter port doesn't decompose into 7 PRs the way web did because Flutter's centralized `theme.dart` means most palette/font work happens in one file. Per-screen Save / Cancel reclassification beyond the calendar event Save is opportunistic — the wiring pattern (`Theme.of(context).extension<ActionColors>()!.primary`) is established and applied incrementally as files are touched.
|
||||
|
||||
Pattern reference for downstream screens: see `lib/screens/calendar/event_form_sheet.dart` for `ActionColors.primary` usage on Save buttons; see the dialog spots in `note_edit_screen.dart` / `task_edit_screen.dart` / `note_detail_screen.dart` / `conversations_tab_screen.dart` for `ActionColors.destructive` on confirm-Delete buttons.
|
||||
|
||||
### Open threads
|
||||
|
||||
*New threads will accumulate here as gaps surface in real use.*
|
||||
|
||||
+12
-9
@@ -52,19 +52,22 @@ Full conversation history with SSE streaming. Features:
|
||||
- **Bulk delete** — Select and delete multiple conversations.
|
||||
- **Retention** — Conversations auto-pruned after configurable days (default 90).
|
||||
|
||||
## Daily Briefing
|
||||
## Daily Journal
|
||||
|
||||
`/briefing` is a scheduled, dialogue-based morning briefing. The assistant compiles tasks, calendar events, projects, weather forecast, and RSS digest at configurable times, then checks in throughout the day. You can reply interactively.
|
||||
`/journal` is a conversational daily surface — each day is a chat-style conversation seeded with an LLM-generated daily prep as the first assistant message. The prep pulls together today's tasks, calendar events, weather, recent moments, and active projects in flowing prose, then invites the user to continue the conversation throughout the day.
|
||||
|
||||
**Schedule** — Configurable slots: morning (default 4am compile), midday (8am check-in), evening (12pm check-in), night (4pm). Scheduler catches up missed slots on startup.
|
||||
**Schedule** — Daily prep generates at a configurable time (default 5:00am). The "day rollover hour" controls when the journal switches to a new day (default 4am — late-night entries 1–3am still count as the previous day). Scheduler catches up missed runs on startup.
|
||||
|
||||
**Configuration** — Settings → Briefing: enable toggle, location geocoding, office days, time slot toggles, RSS feed management, push notification toggle.
|
||||
**Right rail** — The journal view shows current weather conditions and upcoming events for the next two weeks alongside the conversation. Both surfaces draw from the same data the prep references.
|
||||
|
||||
**RSS feeds** — Add feed URLs with optional name and category. Feeds are fetched and cached; the briefing digest includes recent items. Category badges shown in the UI. Feeds can be manually refreshed.
|
||||
**Configuration** — Settings → Profile:
|
||||
- *Locations* section: home and work place-name inputs (geocoded on blur via Nominatim) and a temperature unit toggle (C/F)
|
||||
- *Journal* section: prep auto-generate toggle, prep generation time, day rollover hour
|
||||
- *About You* / *Interests* / *Work Schedule* / *Response Preferences* feed personalization into the prep's system prompt
|
||||
|
||||
**Weather** — Location-based forecast via Open-Meteo. Multiple locations supported (home, work, or any city name). Geocoding via Nominatim.
|
||||
**Weather** — Location-based forecast via Open-Meteo. Up to two named locations (home, work). Cached rows auto-refresh in the background when the journal page loads.
|
||||
|
||||
**Profile note** — The assistant maintains a profile note for each user that it updates based on briefing conversations, improving personalisation over time.
|
||||
**What the assistant has learned** — The assistant maintains a per-user observation log + consolidated summary, generated from journal and chat conversations. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you over time.
|
||||
|
||||
## Web Research
|
||||
|
||||
@@ -115,10 +118,10 @@ Settings are tabbed:
|
||||
|-----|----------|
|
||||
| General | Assistant name, default model, model management (pull/delete) |
|
||||
| Account | Email change, password change, session invalidation |
|
||||
| Notifications | Push notification subscription, briefing push toggle |
|
||||
| Notifications | Push notification subscription, journal prep push toggle |
|
||||
| Profile | About you, response preferences, interests, work schedule, locations + temperature unit, journal prep schedule, learned observations |
|
||||
| Integrations | CalDAV configuration, SearXNG status |
|
||||
| Data | Personal export, backup/restore (admin) |
|
||||
| Briefing | Enable, location, office days, slots, RSS feeds, weather |
|
||||
| API Keys | Create/revoke API keys, Fable MCP download and install |
|
||||
| Config (admin) | Base URL, SMTP, OIDC settings |
|
||||
| Users (admin) | User list, invite links, registration toggle |
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "fable-mcp"
|
||||
version = "0.2.6"
|
||||
version = "0.3.0"
|
||||
description = "MCP server for Fabled Scribe"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
Generated
+1
-1
@@ -184,7 +184,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fable-mcp"
|
||||
version = "0.2.6"
|
||||
version = "0.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
Generated
+807
@@ -24,6 +24,7 @@
|
||||
"@tiptap/vue-3": "^3.0.0",
|
||||
"d3": "^7",
|
||||
"dompurify": "^3.1.0",
|
||||
"lucide-vue-next": "^0.469.0",
|
||||
"marked": "^17.0.0",
|
||||
"pinia": "^3.0.0",
|
||||
"vue": "3.5.30",
|
||||
@@ -91,6 +92,278 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"cpu": [
|
||||
@@ -106,6 +379,159 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"license": "MIT",
|
||||
@@ -284,6 +710,294 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.59.0",
|
||||
"cpu": [
|
||||
@@ -296,6 +1010,90 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.20.1",
|
||||
"license": "MIT",
|
||||
@@ -1908,6 +2706,15 @@
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lucide-vue-next": {
|
||||
"version": "0.469.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.469.0.tgz",
|
||||
"integrity": "sha512-EjOap+vY3xEzCMrnaccDHO4BH3k3Lr+sOyvzRQCaayYxkxKla0w6Jr4h3cHAzA4vMSp63Dcy7vDiGeCPcCY+Gg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@tiptap/vue-3": "^3.0.0",
|
||||
"d3": "^7",
|
||||
"dompurify": "^3.1.0",
|
||||
"lucide-vue-next": "^0.469.0",
|
||||
"marked": "^17.0.0",
|
||||
"pinia": "^3.0.0",
|
||||
"vue": "3.5.30",
|
||||
|
||||
@@ -317,6 +317,7 @@ export interface JournalConfig {
|
||||
day_rollover_hour: number;
|
||||
morning_end_hour?: number;
|
||||
midday_end_hour?: number;
|
||||
closeout_enabled?: boolean;
|
||||
// Ambient-context fields (carried forward from the briefing config schema)
|
||||
locations?: { home?: JournalLocation; work?: JournalLocation };
|
||||
temp_unit?: 'C' | 'F';
|
||||
@@ -545,7 +546,7 @@ export interface EventUpdatePayload {
|
||||
description?: string;
|
||||
location?: string;
|
||||
color?: string;
|
||||
recurrence?: string;
|
||||
recurrence?: string | null;
|
||||
project_id?: number;
|
||||
}
|
||||
|
||||
@@ -696,3 +697,40 @@ export const updateProfile = (data: Partial<UserProfile>) =>
|
||||
export const consolidateProfile = () =>
|
||||
apiPost<{ status: string; learned_summary: string }>('/api/profile/consolidate', {})
|
||||
export const clearProfileObservations = () => apiDelete('/api/profile/observations')
|
||||
|
||||
export interface ProfileObservationEntry {
|
||||
date: string
|
||||
bullets: string
|
||||
}
|
||||
|
||||
export const listProfileObservations = () =>
|
||||
apiGet<{ observations: ProfileObservationEntry[] }>('/api/profile/observations')
|
||||
|
||||
|
||||
// ── Tasks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { Note as Task } from '../types/note'
|
||||
|
||||
/** Manually trigger a consolidation pass for a task. Returns the freshly-
|
||||
* updated task with new body + consolidated_at. Bypasses the user's
|
||||
* auto_consolidate_tasks setting. */
|
||||
export const consolidateTask = (id: number) =>
|
||||
apiPost<Task>(`/api/tasks/${id}/consolidate`, {})
|
||||
|
||||
|
||||
// ── Note Versions (pinning) ──────────────────────────────────────────────────
|
||||
|
||||
import type { NoteVersion } from '../types/task'
|
||||
|
||||
/** Mark a note version as manually pinned, optionally with a commit-note
|
||||
* label. Re-calling with a different label updates the label. */
|
||||
export const pinNoteVersion = (noteId: number, versionId: number, label?: string | null) =>
|
||||
apiPost<NoteVersion>(
|
||||
`/api/notes/${noteId}/versions/${versionId}/pin`,
|
||||
{ label: label ?? null },
|
||||
)
|
||||
|
||||
/** Downgrade a manually-pinned version back to rolling. Does NOT delete
|
||||
* the row — older rows may be FIFO-pruned by the next autosave. */
|
||||
export const unpinNoteVersion = (noteId: number, versionId: number) =>
|
||||
apiDelete(`/api/notes/${noteId}/versions/${versionId}/pin`)
|
||||
|
||||
@@ -50,34 +50,42 @@
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Save: Moss action-primary per the Hybrid rule. Saving is "operating
|
||||
the software" — not a brand moment. Accent gradient is reserved for
|
||||
Send / empty-state CTAs. */
|
||||
.btn-save {
|
||||
padding: 0.45rem 1.1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
|
||||
opacity: 0.95;
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
.btn-save:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
/* Delete: Oxblood action-destructive per Hybrid rule. Should be paired
|
||||
with a Trash icon at the call site to reinforce intent. */
|
||||
.btn-delete {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-danger);
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-delete:hover { background: var(--color-action-destructive-hover); }
|
||||
.btn-assist-toggle {
|
||||
margin-left: auto;
|
||||
padding: 0.4rem 0.9rem;
|
||||
@@ -99,7 +107,7 @@
|
||||
border-bottom: 1.5px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
@@ -112,7 +120,6 @@
|
||||
}
|
||||
.title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
.editor-tabs {
|
||||
@@ -225,7 +232,7 @@
|
||||
.assist-panel-title {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -269,7 +276,7 @@
|
||||
/* Section list */
|
||||
.assist-sections-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -362,7 +369,6 @@
|
||||
.assist-streaming-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -391,7 +397,6 @@
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -411,7 +416,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.btn-toggle-view {
|
||||
@@ -454,7 +459,7 @@
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
}
|
||||
.diff-text {
|
||||
flex: 1;
|
||||
@@ -464,7 +469,6 @@
|
||||
.diff-empty {
|
||||
padding: 0.5rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.assist-actions {
|
||||
@@ -562,7 +566,7 @@
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
@@ -581,7 +585,7 @@
|
||||
}
|
||||
.sb-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
.prose {
|
||||
line-height: 1.6;
|
||||
/* Long-form reading line-height per the design system —
|
||||
reading surfaces (notes, tasks, journal, assistant bubbles) want 1.7
|
||||
so prose breathes like a book, not a UI snippet. */
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
|
||||
+149
-114
@@ -1,131 +1,147 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:ital,wght@0,400;1,400&display=swap');
|
||||
|
||||
:root {
|
||||
--color-bg: #f5f5fb;
|
||||
--color-bg-secondary: #ededf5;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-surface: #f0f0f8;
|
||||
--color-text: #1a1a1a;
|
||||
--color-text-secondary: #666666;
|
||||
--color-text-muted: #999999;
|
||||
--color-border: #dddde8;
|
||||
--color-input-border: #c8c8d8;
|
||||
--color-primary: #7c3aed;
|
||||
--color-danger: #d93025;
|
||||
--color-tag-bg: #ede5ff;
|
||||
--color-tag-text: #6d28d9;
|
||||
/* Light mode — warm parchment palette */
|
||||
--color-bg: #F5F1E8;
|
||||
--color-bg-secondary: #FBF8F0;
|
||||
--color-bg-card: #FBF8F0;
|
||||
--color-surface: #EFEAE0;
|
||||
--color-text: #14171A;
|
||||
--color-text-secondary: #5A5852;
|
||||
--color-text-muted: #9A9890;
|
||||
--color-border: #D9D6CE;
|
||||
--color-input-border: #D9D6CE;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-tag-text: #5B4A8A;
|
||||
--color-shadow: rgba(0, 0, 0, 0.08);
|
||||
--color-toast-success: #34a853;
|
||||
--color-toast-error: #d93025;
|
||||
--color-status-todo: #5f6368;
|
||||
--color-status-todo-bg: #e8eaed;
|
||||
--color-status-in-progress: #7c3aed;
|
||||
--color-status-in-progress-bg: #ede5ff;
|
||||
--color-status-done: #34a853;
|
||||
--color-status-done-bg: #e6f4ea;
|
||||
--color-priority-low: #5f9ea0;
|
||||
--color-priority-low-bg: #e0f2f1;
|
||||
--color-priority-medium: #f9a825;
|
||||
--color-priority-medium-bg: #fff8e1;
|
||||
--color-priority-high: #d93025;
|
||||
--color-priority-high-bg: #fce8e6;
|
||||
--color-wikilink: #7b1fa2;
|
||||
--color-wikilink-bg: #f3e5f5;
|
||||
--color-overdue: #d93025;
|
||||
--color-code-bg: #f0f0f8;
|
||||
--color-code-inline-bg: #eaeaf4;
|
||||
--color-table-stripe: #f4f4fb;
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #eab308;
|
||||
--color-input-bar-bg: #eaeaf3;
|
||||
--color-input-bar-text: #1a1a1a;
|
||||
--color-input-bar-placeholder: rgba(0, 0, 0, 0.4);
|
||||
--color-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.10);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.12);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.12);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.12);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.12);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #EBEDF0;
|
||||
--color-code-inline-bg: #EBEDF0;
|
||||
--color-table-stripe: rgba(20, 23, 26, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #EFEAE0;
|
||||
--color-input-bar-text: #14171A;
|
||||
--color-input-bar-placeholder: rgba(20, 23, 26, 0.4);
|
||||
--color-overlay: rgba(0, 0, 0, 0.45);
|
||||
--color-bubble-user-bg: rgba(0, 0, 0, 0.04);
|
||||
--color-bubble-user-border: rgba(0, 0, 0, 0.10);
|
||||
--color-bubble-user-text: #3a3a4a;
|
||||
--color-bubble-asst-shadow: 0 2px 16px rgba(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #D9D6CE;
|
||||
--color-bubble-user-text: #5A5852;
|
||||
--color-bubble-asst-shadow: 0 2px 14px rgba(91, 74, 138, 0.06), 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 10px rgba(91, 74, 138, 0.35);
|
||||
--glow-cta-hover: 0 4px 20px rgba(91, 74, 138, 0.55);
|
||||
--glow-soft: 0 0 16px rgba(91, 74, 138, 0.35);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.08);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.12);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.20);
|
||||
|
||||
/* Action color set — Hybrid rule: action buttons use these, accent reserved for brand moments */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--radius-pill: 9999px;
|
||||
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
|
||||
--focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
|
||||
/* Layout */
|
||||
--page-max-width: 1200px;
|
||||
--page-padding-x: 1rem;
|
||||
--sidebar-width: 260px;
|
||||
--chat-reading-width: min(1200px, 100%);
|
||||
--chat-context-sidebar-width: 220px;
|
||||
--color-accent-warm: #b8860b;
|
||||
--color-accent-warm-light: #d4a017;
|
||||
--color-primary-solid: #7c3aed;
|
||||
--color-primary-deep: #5b21b6;
|
||||
/* Reusable brand patterns */
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 10px rgba(124, 58, 237, 0.35);
|
||||
--glow-cta-hover: 0 4px 20px rgba(124, 58, 237, 0.55);
|
||||
--glow-soft: 0 0 16px rgba(124, 58, 237, 0.35);
|
||||
--color-primary-faint: rgba(124, 58, 237, 0.08);
|
||||
--color-primary-tint: rgba(124, 58, 237, 0.12);
|
||||
--color-primary-wash: rgba(124, 58, 237, 0.20);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #0f0f14;
|
||||
--color-bg-secondary: #16161f;
|
||||
--color-bg-card: #1a1a24;
|
||||
--color-surface: #16161f;
|
||||
--color-text: #e4e4f0;
|
||||
--color-text-secondary: #8888a8;
|
||||
--color-text-muted: #52526a;
|
||||
--color-border: rgba(124, 58, 237, 0.22);
|
||||
--color-input-border: rgba(124, 58, 237, 0.35);
|
||||
--color-primary: #a78bfa;
|
||||
--color-danger: #f44336;
|
||||
--color-tag-bg: #2a2a45;
|
||||
--color-tag-text: #c4b5fd;
|
||||
/* Dark mode — Obsidian / Iron / Pewter */
|
||||
--color-bg: #14171A;
|
||||
--color-bg-secondary: #1E2228;
|
||||
--color-bg-card: #1E2228;
|
||||
--color-surface: #2C313A;
|
||||
--color-text: #E8E4D8;
|
||||
--color-text-secondary: #C2BFB4;
|
||||
--color-text-muted: #9C9A92;
|
||||
--color-border: #3F4651;
|
||||
--color-input-border: #3F4651;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.15);
|
||||
--color-tag-text: #5B4A8A;
|
||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
||||
--color-toast-success: #4caf50;
|
||||
--color-toast-error: #f44336;
|
||||
--color-status-todo: #9aa0a6;
|
||||
--color-status-todo-bg: #2a2a35;
|
||||
--color-status-in-progress: #a78bfa;
|
||||
--color-status-in-progress-bg: #2a2a45;
|
||||
--color-status-done: #4caf50;
|
||||
--color-status-done-bg: #1b3a20;
|
||||
--color-priority-low: #80cbc4;
|
||||
--color-priority-low-bg: #1a3a38;
|
||||
--color-priority-medium: #fdd835;
|
||||
--color-priority-medium-bg: #3a3520;
|
||||
--color-priority-high: #f44336;
|
||||
--color-priority-high-bg: #3a1a1a;
|
||||
--color-wikilink: #c4b5fd;
|
||||
--color-wikilink-bg: #2a1a45;
|
||||
--color-overdue: #f44336;
|
||||
--color-code-bg: #12121a;
|
||||
--color-code-inline-bg: #1a1a2a;
|
||||
--color-table-stripe: #14141e;
|
||||
--color-success: #4ade80;
|
||||
--color-warning: #facc15;
|
||||
--color-input-bar-bg: #1a1a24;
|
||||
--color-input-bar-text: #e4e4f0;
|
||||
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
|
||||
--color-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.18);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.18);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.18);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.18);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.18);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #14171A;
|
||||
--color-code-inline-bg: #1E2228;
|
||||
--color-table-stripe: rgba(255, 255, 255, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #1E2228;
|
||||
--color-input-bar-text: #E8E4D8;
|
||||
--color-input-bar-placeholder: rgba(232, 228, 216, 0.35);
|
||||
--color-overlay: rgba(0, 0, 0, 0.65);
|
||||
--color-bubble-user-bg: rgba(255, 255, 255, 0.04);
|
||||
--color-bubble-user-border: rgba(255, 255, 255, 0.10);
|
||||
--color-bubble-user-text: #b0b0c8;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(124, 58, 237, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--color-accent-warm: #d4a017;
|
||||
--color-accent-warm-light: #e8c45a;
|
||||
--color-primary-solid: #7c3aed;
|
||||
--color-primary-deep: #5b21b6;
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #3F4651;
|
||||
--color-bubble-user-text: #C2BFB4;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(91, 74, 138, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 12px rgba(124, 58, 237, 0.45);
|
||||
--glow-cta-hover: 0 4px 24px rgba(124, 58, 237, 0.65);
|
||||
--glow-soft: 0 0 18px rgba(124, 58, 237, 0.4);
|
||||
--color-primary-faint: rgba(124, 58, 237, 0.10);
|
||||
--color-primary-tint: rgba(124, 58, 237, 0.14);
|
||||
--color-primary-wash: rgba(124, 58, 237, 0.22);
|
||||
--glow-cta: 0 2px 12px rgba(91, 74, 138, 0.45);
|
||||
--glow-cta-hover: 0 4px 24px rgba(91, 74, 138, 0.65);
|
||||
--glow-soft: 0 0 18px rgba(91, 74, 138, 0.4);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.10);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.14);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.22);
|
||||
|
||||
/* Action color set — identical across themes */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -138,15 +154,34 @@ body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
font-feature-settings: "cv11";
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
h1, h2 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
code, pre, kbd, samp {
|
||||
font-family: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-feature-settings: "liga", "calt";
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(91, 74, 138, 0.3);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
@@ -184,7 +219,7 @@ button:not(:disabled):active,
|
||||
}
|
||||
}
|
||||
|
||||
/* Thin indigo-tinted scrollbars */
|
||||
/* Neutral hairline scrollbars — chrome is structural, not branded */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
@@ -193,11 +228,11 @@ button:not(:disabled):active,
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(124, 58, 237, 0.25);
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(124, 58, 237, 0.45);
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Floating inline assist button (teleported to body, cannot be scoped) */
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from "@/stores/auth";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import NotificationBell from "@/components/NotificationBell.vue";
|
||||
import { Sun, Moon, Settings } from "lucide-vue-next";
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { toggleShortcuts } = useShortcuts();
|
||||
@@ -94,15 +95,13 @@ router.afterEach(() => {
|
||||
<button class="btn-icon" @click="toggleShortcuts" title="Keyboard shortcuts (?)">?</button>
|
||||
|
||||
<button class="btn-icon" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
|
||||
{{ theme === "dark" ? "\u2600" : "\u263E" }}
|
||||
<Sun v-if="theme === 'dark'" :size="16" />
|
||||
<Moon v-else :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- Settings link -->
|
||||
<router-link to="/settings" class="btn-icon" aria-label="Settings" title="Settings">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<Settings :size="16" />
|
||||
</router-link>
|
||||
|
||||
<div class="user-info">
|
||||
@@ -137,7 +136,8 @@ router.afterEach(() => {
|
||||
<span class="status-text">{{ statusShortLabel }}</span>
|
||||
</span>
|
||||
<button class="btn-icon" @click="toggleShortcuts">?</button>
|
||||
<button class="btn-icon" @click="toggleTheme">{{ theme === "dark" ? "\u2600" : "\u263E" }}</button>
|
||||
<button class="btn-icon" @click="toggleTheme"><Sun v-if="theme === 'dark'" :size="16" />
|
||||
<Moon v-else :size="16" /></button>
|
||||
</div>
|
||||
<div class="mobile-user">
|
||||
<span class="username">{{ authStore.user?.username }}</span>
|
||||
@@ -151,7 +151,7 @@ router.afterEach(() => {
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||
border-bottom: 1px solid rgba(124, 58, 237, 0.18);
|
||||
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
@@ -172,9 +172,8 @@ router.afterEach(() => {
|
||||
}
|
||||
.brand-text {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: #c4b0f0;
|
||||
@@ -219,9 +218,9 @@ router.afterEach(() => {
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: var(--color-primary-solid);
|
||||
font-weight: 600;
|
||||
background: rgba(124, 58, 237, 0.25);
|
||||
box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
|
||||
font-weight: 500;
|
||||
background: rgba(91, 74, 138, 0.25);
|
||||
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
@@ -243,10 +242,14 @@ router.afterEach(() => {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; }
|
||||
.status-yellow .status-dot { background: var(--color-warning, #f59e0b); animation: pulse-dot 2s infinite; }
|
||||
/* Status dots are indicator lights, not semantic-palette buttons —
|
||||
they want to read as vital (Moss/Warning/Error are too muted for
|
||||
a "ready" indicator). Hardcoded bright values; the rest of the
|
||||
system still uses the semantic tokens. */
|
||||
.status-green .status-dot { background: #4ade80; animation: status-pulse 2.5s ease-in-out infinite; }
|
||||
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
|
||||
.status-orange .status-dot { background: #f97316; }
|
||||
.status-red .status-dot { background: var(--color-danger, #e74c3c); }
|
||||
.status-red .status-dot { background: #ef4444; }
|
||||
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
@@ -294,7 +297,7 @@ router.afterEach(() => {
|
||||
}
|
||||
.admin-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-primary);
|
||||
|
||||
@@ -10,6 +10,15 @@ import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { Note } from '@/types/note'
|
||||
import {
|
||||
Paperclip,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Square,
|
||||
Mic,
|
||||
Loader2,
|
||||
ArrowUp,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Textarea placeholder */
|
||||
@@ -277,9 +286,7 @@ defineExpose({ focus, prefill })
|
||||
:disabled="!store.chatReady"
|
||||
title="Attach a note"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
<Paperclip :size="16" />
|
||||
</button>
|
||||
<div v-if="showNotePicker" class="note-picker-dropdown">
|
||||
<input
|
||||
@@ -322,12 +329,8 @@ defineExpose({ focus, prefill })
|
||||
:title="listenMode ? 'Listen mode on' : 'Listen / volume'"
|
||||
aria-label="Listen and volume settings"
|
||||
>
|
||||
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||
</svg>
|
||||
<Volume2 v-if="!tts.speaking.value" :size="16" />
|
||||
<VolumeX v-else :size="16" />
|
||||
</button>
|
||||
<div v-if="speakerPopoverOpen" class="speaker-popover">
|
||||
<button
|
||||
@@ -354,7 +357,7 @@ defineExpose({ focus, prefill })
|
||||
class="speaker-row speaker-row--stop"
|
||||
@click="tts.stop()"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||||
<Square :size="16" fill="currentColor" />
|
||||
<span>Stop playback</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -371,12 +374,8 @@ defineExpose({ focus, prefill })
|
||||
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
|
||||
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
|
||||
>
|
||||
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
<Mic v-if="!transcribingVoice" :size="16" />
|
||||
<Loader2 v-else :size="16" class="mic-loader" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -387,14 +386,14 @@ defineExpose({ focus, prefill })
|
||||
@click="emit('abort')"
|
||||
title="Stop generation"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||||
<Square :size="16" fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-send"
|
||||
@click="onSubmit"
|
||||
:disabled="!messageInput.trim() || !store.chatReady"
|
||||
>↑</button>
|
||||
><ArrowUp :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -484,6 +483,8 @@ defineExpose({ focus, prefill })
|
||||
z-index: 1;
|
||||
}
|
||||
.btn-mic.mic-transcribing { opacity: 0.5; }
|
||||
.mic-loader { animation: mic-spin 1s linear infinite; }
|
||||
@keyframes mic-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Mic wrapper + live amplitude halo. The glow is a filled red disc
|
||||
absolutely positioned behind the button, scaled/faded by the
|
||||
@@ -573,7 +574,7 @@ defineExpose({ focus, prefill })
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
|
||||
.btn-send:hover { box-shadow: 0 0 16px rgba(91, 74, 138, 0.35); }
|
||||
.btn-send:disabled { opacity: 0.35; cursor: default; box-shadow: none; }
|
||||
|
||||
.btn-abort-inline {
|
||||
|
||||
@@ -155,7 +155,7 @@ const timingParts = computed((): string[] => {
|
||||
}
|
||||
.role-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
@@ -167,7 +167,6 @@ const timingParts = computed((): string[] => {
|
||||
color: var(--color-primary);
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-optical-sizing: auto;
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
@@ -175,14 +174,14 @@ const timingParts = computed((): string[] => {
|
||||
|
||||
.thinking-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border-left: 2px solid rgba(129, 140, 248, 0.35);
|
||||
border-left: 2px solid rgba(91, 74, 138, 0.35);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.thinking-summary {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -215,6 +214,12 @@ details[open] .thinking-summary::before {
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
/* Long-form line-height (1.7) on assistant bubbles per the design system —
|
||||
chat is a reading surface, not a snippet stream. User bubbles stay tighter. */
|
||||
.role-assistant .message-content {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
|
||||
@@ -6,6 +6,7 @@ import ChatStreamingBubble from '@/components/ChatStreamingBubble.vue'
|
||||
import ChatInputBar from '@/components/ChatInputBar.vue'
|
||||
import ToolCallCard from '@/components/ToolCallCard.vue'
|
||||
import type { Message, ToolCallRecord } from '@/types/chat'
|
||||
import { Mic, ChevronDown, X } from 'lucide-vue-next'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant: 'full' | 'widget'
|
||||
@@ -372,7 +373,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
</router-link>
|
||||
</div>
|
||||
<button class="empty-voice-btn" @click="startVoiceMode" title="Start in voice mode">
|
||||
<span class="voice-icon">🎤</span>
|
||||
<Mic class="voice-icon" :size="16" />
|
||||
<span>Start in voice mode</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -391,7 +392,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
:class="{ collapsed: collapsedSections.has('auto') }"
|
||||
@click="toggleSection('auto')"
|
||||
>
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
|
||||
<ChevronDown class="chev" :size="16" />
|
||||
<span>Auto-included</span>
|
||||
<span class="section-count">{{ autoInjectedNotes.length }}</span>
|
||||
</button>
|
||||
@@ -404,7 +405,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
class="context-note-score"
|
||||
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
|
||||
>{{ Math.round(note.score * 100) }}%</span>
|
||||
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context">×</button>
|
||||
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -414,7 +415,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
:class="{ collapsed: collapsedSections.has('suggested'), 'context-sidebar-header-gap': autoInjectedNotes.length }"
|
||||
@click="toggleSection('suggested')"
|
||||
>
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
|
||||
<ChevronDown class="chev" :size="16" />
|
||||
<span>Suggested</span>
|
||||
<span class="section-count">{{ suggestedNotes.length }}</span>
|
||||
</button>
|
||||
@@ -436,14 +437,14 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
:class="{ collapsed: collapsedSections.has('included'), 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }"
|
||||
@click="toggleSection('included')"
|
||||
>
|
||||
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
|
||||
<ChevronDown class="chev" :size="16" />
|
||||
<span>In Context</span>
|
||||
<span class="section-count">{{ includedNotes.length }}</span>
|
||||
</button>
|
||||
<div v-show="!collapsedSections.has('included')">
|
||||
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
|
||||
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
|
||||
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">×</button>
|
||||
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -579,7 +580,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
padding: 0.15rem 0.1rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -597,7 +598,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
.context-sidebar-header .section-count {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
background: var(--color-bg);
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 8px;
|
||||
@@ -621,7 +622,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
}
|
||||
.auto-pill {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
@@ -685,7 +686,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.queued-clear-row {
|
||||
@@ -703,13 +704,11 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
}
|
||||
|
||||
.chat-empty-state {
|
||||
@@ -724,17 +723,16 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
|
||||
.empty-greeting {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 1.75rem;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -768,7 +766,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
|
||||
.empty-recent-item:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
background: rgba(91, 74, 138, 0.05);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
@@ -805,9 +803,9 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
|
||||
.empty-voice-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
background: linear-gradient(135deg, #5B4A8A, #3F3560);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35);
|
||||
box-shadow: 0 4px 16px rgba(91, 74, 138, 0.35);
|
||||
}
|
||||
|
||||
.empty-voice-btn .voice-icon {
|
||||
@@ -828,7 +826,6 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
.widget-query {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.widget-text {
|
||||
font-size: 0.9rem;
|
||||
@@ -871,7 +868,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
.btn-open-chat.prominent {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-clear-response {
|
||||
background: none;
|
||||
@@ -918,7 +915,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
|
||||
:deep(.thinking-summary) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -124,7 +124,6 @@ function markerFor(type: DiffLine['type']): string {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
@@ -153,7 +152,6 @@ function markerFor(type: DiffLine['type']): string {
|
||||
.diff-collapse {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
border-bottom: 1px dashed var(--color-border);
|
||||
padding-top: 0.2rem;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
@@ -36,6 +37,36 @@ const description = ref("");
|
||||
const location = ref("");
|
||||
const color = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
const recurrence = ref<string>("");
|
||||
|
||||
// Preset RRULE strings. The select binds to `recurrencePreset`, which writes
|
||||
// through to `recurrence`. CalDAV-imported rules with extra parts
|
||||
// (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`) fall through to "custom" and the raw
|
||||
// string is shown read-only below the select.
|
||||
const RECURRENCE_PRESETS: Record<string, string> = {
|
||||
none: "",
|
||||
daily: "FREQ=DAILY",
|
||||
weekly: "FREQ=WEEKLY",
|
||||
monthly: "FREQ=MONTHLY",
|
||||
yearly: "FREQ=YEARLY",
|
||||
};
|
||||
|
||||
const recurrencePreset = computed<string>({
|
||||
get() {
|
||||
const r = (recurrence.value || "").trim();
|
||||
if (!r) return "none";
|
||||
for (const [key, val] of Object.entries(RECURRENCE_PRESETS)) {
|
||||
if (val && val === r) return key;
|
||||
}
|
||||
return "custom";
|
||||
},
|
||||
set(key: string) {
|
||||
if (key === "custom") return; // no-op; can't pick custom from dropdown
|
||||
recurrence.value = RECURRENCE_PRESETS[key] ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
const isCustomRecurrence = computed(() => recurrencePreset.value === "custom");
|
||||
|
||||
function dateFromIso(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
@@ -114,6 +145,7 @@ function resetForm() {
|
||||
location.value = props.event.location || "";
|
||||
color.value = props.event.color || "";
|
||||
projectId.value = props.event.project_id;
|
||||
recurrence.value = props.event.recurrence || "";
|
||||
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
|
||||
if (_lastDurationMin <= 0) _lastDurationMin = 60;
|
||||
} else {
|
||||
@@ -129,6 +161,7 @@ function resetForm() {
|
||||
location.value = "";
|
||||
color.value = "";
|
||||
projectId.value = null;
|
||||
recurrence.value = "";
|
||||
_lastDurationMin = 60;
|
||||
}
|
||||
deleteConfirm.value = false;
|
||||
@@ -176,26 +209,69 @@ watch(() => props.event, resetForm, { immediate: true });
|
||||
watch(() => props.initialDate, resetForm);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") emit("close");
|
||||
if (e.key === "Escape") {
|
||||
if (deleteConfirm.value) {
|
||||
// Esc cancels the delete-confirm rather than closing the modal —
|
||||
// gives the user a clear way out of the destructive prompt.
|
||||
deleteConfirm.value = false;
|
||||
return;
|
||||
}
|
||||
attemptClose();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("keydown", handleKeydown));
|
||||
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
|
||||
|
||||
async function save() {
|
||||
// ── Close / save flow ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// All exit paths (X button, Esc, backdrop click) funnel through `attemptClose`.
|
||||
// The Save button is gone — explicit-commit is replaced with auto-save-on-close.
|
||||
//
|
||||
// Validity-aware behavior:
|
||||
// - Form valid → save (PATCH for edit, POST for create), then close.
|
||||
// - Form invalid in EDIT mode → discard the in-memory change and close.
|
||||
// A toast tells the user what happened so they don't think their edit
|
||||
// silently landed.
|
||||
// - Form invalid in CREATE mode → close silently (nothing existed to begin
|
||||
// with; no need to call this out).
|
||||
|
||||
function isFormValid(): { valid: boolean; reason?: string } {
|
||||
if (!title.value.trim()) {
|
||||
toast.show("Title is required", "error");
|
||||
return;
|
||||
return { valid: false, reason: "Title required" };
|
||||
}
|
||||
if (!startDate.value) {
|
||||
toast.show("Start date is required", "error");
|
||||
return;
|
||||
return { valid: false, reason: "Start date required" };
|
||||
}
|
||||
if (!allDay.value && !startTime.value) {
|
||||
toast.show("Start time is required", "error");
|
||||
return;
|
||||
return { valid: false, reason: "Start time required" };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
let _closing = false;
|
||||
|
||||
async function attemptClose() {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
try {
|
||||
const validity = isFormValid();
|
||||
if (!validity.valid) {
|
||||
if (isEditMode.value) {
|
||||
toast.show(`${validity.reason} — change discarded`, "warning");
|
||||
}
|
||||
// Create mode + invalid: silent close. Nothing was committed.
|
||||
emit("close");
|
||||
return;
|
||||
}
|
||||
await save();
|
||||
emit("close");
|
||||
} finally {
|
||||
_closing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
|
||||
const end_dt = endDate.value
|
||||
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
|
||||
@@ -213,9 +289,9 @@ async function save() {
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || null,
|
||||
};
|
||||
const updated = await updateEvent(props.event.id, payload);
|
||||
toast.show("Event updated", "success");
|
||||
emit("updated", updated);
|
||||
} else {
|
||||
const payload: EventCreatePayload = {
|
||||
@@ -227,9 +303,9 @@ async function save() {
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || undefined,
|
||||
};
|
||||
const created = await createEvent(payload);
|
||||
toast.show("Event created", "success");
|
||||
emit("created", created);
|
||||
}
|
||||
} catch {
|
||||
@@ -255,23 +331,57 @@ async function doDelete() {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="slide-over-backdrop" @click.self="emit('close')">
|
||||
<div class="slide-over-panel" role="dialog" aria-modal="true">
|
||||
<div class="so-header">
|
||||
<h2 class="so-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
||||
<button class="so-close" @click="emit('close')" aria-label="Close">✕</button>
|
||||
<div class="modal-backdrop" @click.self="attemptClose">
|
||||
<div class="modal-panel" role="dialog" aria-modal="true">
|
||||
<!-- Header: trash + close (or inline delete-confirm) -->
|
||||
<div class="modal-header">
|
||||
<template v-if="!deleteConfirm">
|
||||
<h2 class="modal-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
class="header-btn header-btn-danger"
|
||||
@click="deleteConfirm = true"
|
||||
title="Delete event"
|
||||
aria-label="Delete event"
|
||||
><Trash2 :size="16" /></button>
|
||||
<button
|
||||
class="header-btn"
|
||||
@click="attemptClose"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
><X :size="16" /></button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="delete-confirm-prompt">Delete this event?</span>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="deleting"
|
||||
@click="doDelete"
|
||||
>{{ deleting ? "Deleting…" : "Yes, delete" }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-confirm-cancel"
|
||||
@click="deleteConfirm = false"
|
||||
>No</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<form class="so-form" @submit.prevent="save">
|
||||
<!-- Body: form (scrolls if it gets long) -->
|
||||
<form class="modal-form" @submit.prevent="attemptClose">
|
||||
<!-- Title -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Title <span class="required">*</span></label>
|
||||
<input v-model="title" class="so-input" placeholder="Event title" autofocus />
|
||||
<div class="form-field">
|
||||
<label class="form-label">Title <span class="required">*</span></label>
|
||||
<input v-model="title" class="form-input" placeholder="Event title" autofocus />
|
||||
</div>
|
||||
|
||||
<!-- All-day toggle -->
|
||||
<div class="so-field so-field-row">
|
||||
<label class="so-label so-label-inline">All day</label>
|
||||
<div class="form-field form-field-row">
|
||||
<label class="form-label form-label-inline">All day</label>
|
||||
<button
|
||||
type="button"
|
||||
:class="['toggle-btn', { active: allDay }]"
|
||||
@@ -280,74 +390,73 @@ async function doDelete() {
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Start <span class="required">*</span></label>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Start <span class="required">*</span></label>
|
||||
<div class="dt-row">
|
||||
<input v-model="startDate" type="date" class="so-input dt-date" required />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" required />
|
||||
<input v-model="startDate" type="date" class="form-input dt-date" required />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="form-input dt-time" required />
|
||||
</div>
|
||||
<p v-if="isPastEvent" class="so-past-hint">This event is in the past</p>
|
||||
<p v-if="isPastEvent" class="form-past-hint">This event is in the past</p>
|
||||
</div>
|
||||
|
||||
<!-- End -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">End</label>
|
||||
<div class="form-field">
|
||||
<label class="form-label">End</label>
|
||||
<div class="dt-row">
|
||||
<input v-model="endDate" type="date" class="so-input dt-date" :min="startDate" />
|
||||
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
|
||||
<input v-model="endDate" type="date" class="form-input dt-date" :min="startDate" />
|
||||
<input v-if="!allDay" v-model="endTime" type="time" class="form-input dt-time" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recurrence -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Repeat</label>
|
||||
<select v-model="recurrencePreset" class="form-input">
|
||||
<option value="none">Does not repeat</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
<option v-if="isCustomRecurrence" value="custom" disabled>Custom</option>
|
||||
</select>
|
||||
<p v-if="isCustomRecurrence" class="recurrence-custom-hint">
|
||||
Custom rule: <code>{{ recurrence }}</code>
|
||||
<br />
|
||||
<span class="form-hint">Picking a preset will replace this rule.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Location <span class="so-hint">(optional)</span></label>
|
||||
<input v-model="location" class="so-input" placeholder="Location" />
|
||||
<div class="form-field">
|
||||
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
|
||||
<input v-model="location" class="form-input" placeholder="Location" />
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Description <span class="so-hint">(optional)</span></label>
|
||||
<textarea v-model="description" class="so-input so-textarea" placeholder="Description" rows="3" />
|
||||
<div class="form-field">
|
||||
<label class="form-label">Description <span class="form-hint">(optional)</span></label>
|
||||
<textarea v-model="description" class="form-input form-textarea" placeholder="Description" rows="3" />
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="so-field so-field-row">
|
||||
<label class="so-label so-label-inline">Color</label>
|
||||
<div class="form-field form-field-row">
|
||||
<label class="form-label form-label-inline">Color</label>
|
||||
<div class="color-row">
|
||||
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
|
||||
<input v-model="color" class="so-input color-hex" placeholder="#7c3aed" />
|
||||
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''">✕</button>
|
||||
<input v-model="color" class="form-input color-hex" placeholder="#5B4A8A" />
|
||||
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project -->
|
||||
<div class="so-field">
|
||||
<label class="so-label">Project <span class="so-hint">(optional)</span></label>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Project <span class="form-hint">(optional)</span></label>
|
||||
<ProjectSelector v-model="projectId" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="so-actions">
|
||||
<button type="submit" class="btn-primary" :disabled="saving">
|
||||
{{ saving ? "Saving…" : (isEditMode ? "Save" : "Create") }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" @click="emit('close')">Cancel</button>
|
||||
<template v-if="isEditMode">
|
||||
<button
|
||||
v-if="!deleteConfirm"
|
||||
type="button"
|
||||
class="btn-danger-ghost"
|
||||
@click="deleteConfirm = true"
|
||||
>Delete</button>
|
||||
<template v-else>
|
||||
<span class="delete-confirm-label">Delete this event?</span>
|
||||
<button type="button" class="btn-danger" :disabled="deleting" @click="doDelete">
|
||||
{{ deleting ? "Deleting…" : "Yes, delete" }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" @click="deleteConfirm = false">No</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<!-- A hidden submit so Enter inside text inputs triggers attemptClose,
|
||||
matching the no-explicit-Save-button intent: Enter commits. -->
|
||||
<button type="submit" class="hidden-submit" :disabled="saving" tabindex="-1" aria-hidden="true" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,81 +464,110 @@ async function doDelete() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-over-backdrop {
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.slide-over-panel {
|
||||
.modal-panel {
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
border-left: 1px solid var(--color-border, #2a2b30);
|
||||
width: min(440px, 100vw);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 12px;
|
||||
width: min(480px, 100%);
|
||||
max-height: calc(100vh - 2.5rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.so-header {
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem 0.85rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #2a2b30);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
z-index: 1;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.so-title {
|
||||
.modal-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.so-close {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: var(--color-hover, rgba(255,255,255,0.06));
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
.so-close:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
||||
|
||||
.so-form {
|
||||
padding: 1.25rem 1.5rem;
|
||||
/* Trash in header: subtle until hover, then Oxblood. Lower visual weight
|
||||
than Save would have been — destructive actions shouldn't loom. */
|
||||
.header-btn-danger:hover {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Inline delete-confirm prompt replaces the title row */
|
||||
.delete-confirm-prompt {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Form scrolls inside the panel when content overflows */
|
||||
.modal-form {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.so-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.so-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||
|
||||
.so-label {
|
||||
.form-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.so-label-inline { flex-shrink: 0; margin: 0; }
|
||||
.so-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
||||
.form-label-inline { flex-shrink: 0; margin: 0; }
|
||||
.form-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
||||
|
||||
.required { color: #f87171; }
|
||||
|
||||
.so-input {
|
||||
.form-input {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text, #e8e9f0);
|
||||
@@ -440,18 +578,33 @@ async function doDelete() {
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.so-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.form-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
||||
.form-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
||||
|
||||
.dt-row { display: flex; gap: 0.5rem; }
|
||||
.dt-date { flex: 1; }
|
||||
.dt-time { width: 7.5rem; flex-shrink: 0; }
|
||||
.so-past-hint {
|
||||
.form-past-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-accent-warm, #d4a017);
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.recurrence-custom-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.recurrence-custom-hint code {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
@@ -482,67 +635,43 @@ async function doDelete() {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.so-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border, #2a2b30);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary:hover:not(:disabled) { opacity: 0.88; }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
||||
|
||||
.btn-danger-ghost {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid #ef4444;
|
||||
color: #ef4444;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger-ghost:hover { background: rgba(239, 68, 68, 0.1); }
|
||||
|
||||
/* Confirm-delete buttons (only shown during the inline confirm flow) */
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.delete-confirm-label {
|
||||
.btn-confirm-cancel {
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
align-self: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-confirm-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* Hidden submit lets Enter-in-text-input trigger the same close-with-save
|
||||
path as X / Esc / backdrop. No visible Save button needed. */
|
||||
.hidden-submit {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import type { DiffLine } from "@/composables/useAssist";
|
||||
|
||||
@@ -10,6 +10,8 @@ interface NoteVersion {
|
||||
title: string;
|
||||
tags: string[];
|
||||
body?: string;
|
||||
pin_kind: "auto" | "manual" | null;
|
||||
pin_label: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -105,6 +107,69 @@ function restore() {
|
||||
emit('restore', selectedVersion.value.body, selectedVersion.value.tags ?? []);
|
||||
}
|
||||
|
||||
// ── Pin / unpin / edit-label state ──────────────────────────────────────────
|
||||
|
||||
const editingLabel = ref(false);
|
||||
const draftLabel = ref("");
|
||||
const pinSaving = ref(false);
|
||||
|
||||
function startEditLabel() {
|
||||
if (!selectedVersion.value) return;
|
||||
draftLabel.value = selectedVersion.value.pin_label ?? "";
|
||||
editingLabel.value = true;
|
||||
}
|
||||
|
||||
function cancelEditLabel() {
|
||||
editingLabel.value = false;
|
||||
draftLabel.value = "";
|
||||
}
|
||||
|
||||
async function savePin() {
|
||||
if (!selectedVersion.value || pinSaving.value) return;
|
||||
pinSaving.value = true;
|
||||
try {
|
||||
const updated = await pinNoteVersion(
|
||||
props.noteId, selectedVersion.value.id, draftLabel.value || null,
|
||||
);
|
||||
// Reflect server state in the local list + selected version.
|
||||
const idx = versions.value.findIndex(v => v.id === updated.id);
|
||||
if (idx >= 0) {
|
||||
versions.value[idx].pin_kind = updated.pin_kind;
|
||||
versions.value[idx].pin_label = updated.pin_label;
|
||||
}
|
||||
if (selectedVersion.value) {
|
||||
selectedVersion.value.pin_kind = updated.pin_kind;
|
||||
selectedVersion.value.pin_label = updated.pin_label;
|
||||
}
|
||||
editingLabel.value = false;
|
||||
} catch {
|
||||
// silent — leaving the form open lets the user retry
|
||||
} finally {
|
||||
pinSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unpinSelected() {
|
||||
if (!selectedVersion.value || pinSaving.value) return;
|
||||
pinSaving.value = true;
|
||||
try {
|
||||
await unpinNoteVersion(props.noteId, selectedVersion.value.id);
|
||||
const idx = versions.value.findIndex(v => v.id === selectedVersion.value!.id);
|
||||
if (idx >= 0) {
|
||||
versions.value[idx].pin_kind = null;
|
||||
versions.value[idx].pin_label = null;
|
||||
}
|
||||
if (selectedVersion.value) {
|
||||
selectedVersion.value.pin_kind = null;
|
||||
selectedVersion.value.pin_label = null;
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
pinSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadVersions);
|
||||
</script>
|
||||
|
||||
@@ -128,7 +193,25 @@ onMounted(loadVersions);
|
||||
:class="['history-item', { selected: selectedVersion?.id === v.id }]"
|
||||
@click="selectVersionItem(v)"
|
||||
>
|
||||
<div class="history-item-title">{{ v.title || 'Untitled' }}</div>
|
||||
<div class="history-item-title">
|
||||
<span
|
||||
v-if="v.pin_kind === 'manual'"
|
||||
class="pin-badge pin-badge-manual"
|
||||
:title="v.pin_label || 'Pinned'"
|
||||
aria-label="manually pinned"
|
||||
>●</span>
|
||||
<span
|
||||
v-else-if="v.pin_kind === 'auto'"
|
||||
class="pin-badge pin-badge-auto"
|
||||
:title="v.pin_label || 'Auto-pinned (stable)'"
|
||||
aria-label="auto-pinned"
|
||||
>◐</span>
|
||||
{{ v.title || 'Untitled' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="v.pin_kind === 'manual' && v.pin_label"
|
||||
class="history-item-label"
|
||||
>{{ v.pin_label }}</div>
|
||||
<div class="history-item-date">{{ formatDate(v.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +220,62 @@ onMounted(loadVersions);
|
||||
<div class="history-diff">
|
||||
<div v-if="loadingDetail" class="history-empty">Loading version...</div>
|
||||
<div v-else-if="!selectedVersion" class="history-empty">Select a version to compare.</div>
|
||||
<DiffView v-else :diff="diff" />
|
||||
<template v-else>
|
||||
<div class="version-pin-controls">
|
||||
<!-- Label editor -->
|
||||
<div v-if="editingLabel" class="pin-label-form">
|
||||
<input
|
||||
v-model="draftLabel"
|
||||
maxlength="500"
|
||||
type="text"
|
||||
class="pin-label-input"
|
||||
placeholder="Optional label (e.g. 'post-network-refresh runbook')"
|
||||
/>
|
||||
<button class="btn-pin-save" :disabled="pinSaving" @click="savePin">
|
||||
{{ pinSaving ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button class="btn-pin-cancel" @click="cancelEditLabel">Cancel</button>
|
||||
</div>
|
||||
|
||||
<!-- Default: kind-aware action row -->
|
||||
<div v-else class="pin-actions">
|
||||
<span v-if="selectedVersion.pin_kind === 'manual'" class="pin-state">
|
||||
<span class="pin-badge pin-badge-manual">●</span>
|
||||
Manually pinned{{ selectedVersion.pin_label ? `: ${selectedVersion.pin_label}` : '' }}
|
||||
</span>
|
||||
<span v-else-if="selectedVersion.pin_kind === 'auto'" class="pin-state">
|
||||
<span class="pin-badge pin-badge-auto">◐</span>
|
||||
Auto-pinned{{ selectedVersion.pin_label ? `: ${selectedVersion.pin_label}` : '' }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="selectedVersion.pin_kind === null"
|
||||
class="btn-pin"
|
||||
:disabled="pinSaving"
|
||||
@click="startEditLabel"
|
||||
>Pin version</button>
|
||||
<button
|
||||
v-else-if="selectedVersion.pin_kind === 'manual'"
|
||||
class="btn-pin-edit"
|
||||
:disabled="pinSaving"
|
||||
@click="startEditLabel"
|
||||
>Edit label</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-pin"
|
||||
:disabled="pinSaving"
|
||||
@click="startEditLabel"
|
||||
>Pin permanently</button>
|
||||
<button
|
||||
v-if="selectedVersion.pin_kind === 'manual'"
|
||||
class="btn-unpin"
|
||||
:disabled="pinSaving"
|
||||
@click="unpinSelected"
|
||||
>Unpin</button>
|
||||
</div>
|
||||
</div>
|
||||
<DiffView :diff="diff" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -244,7 +382,6 @@ onMounted(loadVersions);
|
||||
padding: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.history-footer {
|
||||
@@ -269,4 +406,101 @@ onMounted(loadVersions);
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Pin badges + label rendering ───────────────────────────────────────── */
|
||||
.pin-badge {
|
||||
display: inline-block;
|
||||
width: 0.7rem;
|
||||
text-align: center;
|
||||
margin-right: 0.35rem;
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
.pin-badge-manual { color: var(--color-primary, #6366f1); }
|
||||
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
|
||||
|
||||
.history-item-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-primary, #6366f1);
|
||||
font-style: italic;
|
||||
margin-top: 0.15rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Pin controls above the diff ────────────────────────────────────────── */
|
||||
.version-pin-controls {
|
||||
padding: 0.4rem 0.5rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.pin-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pin-state {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-pin, .btn-pin-edit, .btn-unpin {
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-unpin:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.10);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.pin-label-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.pin-label-input {
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
color: inherit;
|
||||
}
|
||||
.pin-label-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-pin-save, .btn-pin-cancel {
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-pin-save:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
|
||||
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -151,7 +151,6 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Review ── */
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListChecks,
|
||||
Code,
|
||||
SquareCode,
|
||||
Quote,
|
||||
Link as LinkIcon,
|
||||
Brackets,
|
||||
} from "lucide-vue-next";
|
||||
import type { Component } from "vue";
|
||||
|
||||
const props = defineProps<{ editor: Editor | null }>();
|
||||
|
||||
// SVG icons — Material Design paths (24x24) or custom SVG
|
||||
const ICONS: Record<string, string> = {
|
||||
bold: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>`,
|
||||
italic: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/></svg>`,
|
||||
strike: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"/></svg>`,
|
||||
h1: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">1</text></svg>`,
|
||||
h2: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">2</text></svg>`,
|
||||
h3: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">3</text></svg>`,
|
||||
ul: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`,
|
||||
ol: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2zm1-9h1V4H2v1h1zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2zm5-3h13V6H7v2zm0 4h13v-2H7v2zm0 4h13v-2H7v2z"/></svg>`,
|
||||
task: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>`,
|
||||
code: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>`,
|
||||
codeblock: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M20 3H4v10c0 2.21 1.79 4 4 4h6c2.21 0 4-1.79 4-4v-3h2c1.11 0 2-.89 2-2V5c0-1.11-.89-2-2-2zm0 5h-2V5h2v3zM4 19h16v2H4z"/></svg>`,
|
||||
quote: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>`,
|
||||
link: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>`,
|
||||
wikilink: `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3"/><path d="M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3"/></svg>`,
|
||||
const ICONS: Record<string, Component> = {
|
||||
bold: Bold,
|
||||
italic: Italic,
|
||||
strike: Strikethrough,
|
||||
h1: Heading1,
|
||||
h2: Heading2,
|
||||
h3: Heading3,
|
||||
ul: List,
|
||||
ol: ListOrdered,
|
||||
task: ListChecks,
|
||||
code: Code,
|
||||
codeblock: SquareCode,
|
||||
quote: Quote,
|
||||
link: LinkIcon,
|
||||
wikilink: Brackets,
|
||||
};
|
||||
|
||||
const groups = [
|
||||
@@ -81,7 +97,7 @@ const groups = [
|
||||
tabindex="-1"
|
||||
@mousedown.prevent="btn.command()"
|
||||
>
|
||||
<span class="btn-icon" v-html="ICONS[btn.id]" />
|
||||
<component :is="ICONS[btn.id]" class="btn-icon" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="gi < groups.length - 1" class="toolbar-sep" aria-hidden="true" />
|
||||
|
||||
@@ -64,11 +64,11 @@ function goEdit() {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(124, 58, 237, 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.note-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(124, 58, 237, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ function goEdit() {
|
||||
}
|
||||
.note-card.compact:hover {
|
||||
box-shadow: none;
|
||||
background: rgba(124, 58, 237, 0.04);
|
||||
background: rgba(91, 74, 138, 0.04);
|
||||
transform: none;
|
||||
}
|
||||
.note-title-compact {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import NotificationsPanel from './NotificationsPanel.vue'
|
||||
import { Bell } from 'lucide-vue-next'
|
||||
|
||||
const store = useNotificationsStore()
|
||||
const open = ref(false)
|
||||
@@ -45,10 +46,7 @@ onUnmounted(() => {
|
||||
aria-label="Notifications"
|
||||
:title="`${store.count} unread notification${store.count !== 1 ? 's' : ''}`"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
<Bell :size="16" aria-hidden="true" />
|
||||
<span v-if="store.count > 0" class="bell-badge" aria-live="polite">{{ store.count > 99 ? '99+' : store.count }}</span>
|
||||
</button>
|
||||
<NotificationsPanel v-if="open" @close="close" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from "lucide-vue-next";
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
@@ -56,7 +57,7 @@ onMounted(() => store.fetchAll())
|
||||
</p>
|
||||
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
||||
</div>
|
||||
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss">✕</button>
|
||||
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notif-empty">No unread notifications</div>
|
||||
@@ -151,6 +152,5 @@ onMounted(() => store.fetchAll())
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.88rem;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from "lucide-vue-next";
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
searchUsers,
|
||||
@@ -116,7 +117,7 @@ onMounted(async () => {
|
||||
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
|
||||
<header class="share-header">
|
||||
<h2 class="share-title">Share "{{ resourceTitle }}"</h2>
|
||||
<button class="btn-close" @click="emit('close')" aria-label="Close">✕</button>
|
||||
<button class="btn-close" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
|
||||
</header>
|
||||
|
||||
<!-- Add share form -->
|
||||
@@ -183,7 +184,7 @@ onMounted(async () => {
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove">✕</button>
|
||||
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
|
||||
</li>
|
||||
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
|
||||
</ul>
|
||||
@@ -409,7 +410,6 @@ onMounted(async () => {
|
||||
.shares-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -112,11 +112,11 @@ function isOverdue(): boolean {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(124, 58, 237, 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.task-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(124, 58, 237, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
|
||||
@@ -532,10 +532,13 @@ function closeEventSlideOver(changed = false) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Inline ToolCallCard renders inside an assistant bubble — the bubble
|
||||
already contains it, so no outer border per the structural-not-decorative
|
||||
rule. Background tint differentiates it; error state gets a left-edge
|
||||
accent the way the assistant bubble itself uses one. */
|
||||
.tool-call-card {
|
||||
display: inline-block;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.4rem;
|
||||
@@ -543,7 +546,7 @@ function closeEventSlideOver(changed = false) {
|
||||
vertical-align: top;
|
||||
}
|
||||
.tool-call-card.error {
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
border-left: 3px solid var(--color-danger);
|
||||
}
|
||||
.tool-call-card.declined {
|
||||
opacity: 0.55;
|
||||
@@ -566,7 +569,7 @@ function closeEventSlideOver(changed = false) {
|
||||
}
|
||||
|
||||
.tool-label {
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
@@ -636,14 +639,14 @@ function closeEventSlideOver(changed = false) {
|
||||
}
|
||||
.web-search-results .tool-search-item::after { content: none; }
|
||||
|
||||
.tool-event-title { font-weight: 600; color: var(--color-text); }
|
||||
.tool-event-title { font-weight: 500; color: var(--color-text); }
|
||||
.tool-event-time { color: var(--color-text-muted); font-size: 0.75rem; }
|
||||
|
||||
.tool-event-list { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.tool-event-item { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; }
|
||||
.tool-event-item-title { color: var(--color-text); font-size: 0.8rem; }
|
||||
.tool-event-item-time { color: var(--color-text-muted); font-size: 0.75rem; white-space: nowrap; }
|
||||
.tool-event-more { color: var(--color-text-muted); font-size: 0.75rem; font-style: italic; }
|
||||
.tool-event-more { color: var(--color-text-muted); font-size: 0.75rem; }
|
||||
|
||||
.tool-deleted { text-decoration: line-through; opacity: 0.7; }
|
||||
.tool-completed { text-decoration: line-through; color: var(--color-success, #2ecc71); }
|
||||
@@ -652,7 +655,7 @@ function closeEventSlideOver(changed = false) {
|
||||
.tool-note-tag { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
|
||||
.tool-task-priority {
|
||||
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
|
||||
font-size: 0.7rem; font-weight: 500; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; padding: 0.1rem 0.3rem; border-radius: 3px;
|
||||
}
|
||||
.priority-high { color: var(--color-danger, #e74c3c); }
|
||||
@@ -697,12 +700,11 @@ function closeEventSlideOver(changed = false) {
|
||||
.confirm-created {
|
||||
color: var(--color-success, #2ecc71);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.confirm-denied {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.btn-confirm-duplicate {
|
||||
padding: 0.15rem 0.5rem;
|
||||
@@ -792,7 +794,7 @@ function closeEventSlideOver(changed = false) {
|
||||
}
|
||||
.tool-event-card-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -202,7 +202,6 @@ function restore() {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.vh-item {
|
||||
|
||||
@@ -273,6 +273,5 @@ function hasPrecip(day: ForecastDay): boolean {
|
||||
|
||||
.weather-unavailable {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import ChatPanel from '@/components/ChatPanel.vue';
|
||||
import ChatInputBar from '@/components/ChatInputBar.vue';
|
||||
import { RotateCcw, ChevronDown, ChevronUp } from 'lucide-vue-next';
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
@@ -179,7 +180,9 @@ defineExpose({ prefill });
|
||||
:class="{ active: historyOpen }"
|
||||
title="History"
|
||||
@click="toggleHistory"
|
||||
>▾</button>
|
||||
>
|
||||
<ChevronDown :size="16" />
|
||||
</button>
|
||||
<div v-if="historyOpen" class="ws-chat-history-menu">
|
||||
<div v-if="projectConversations.length === 0" class="ws-chat-history-empty">
|
||||
No past conversations
|
||||
@@ -195,11 +198,11 @@ defineExpose({ prefill });
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-sm" title="Restart conversation" @click="restart">↻</button>
|
||||
<button class="btn-icon-sm" title="Restart conversation" @click="restart">
|
||||
<RotateCcw :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" title="Collapse" @click="collapse">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
<ChevronDown :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,9 +220,7 @@ defineExpose({ prefill });
|
||||
<!-- Collapsed: standalone input bar with expand affordance -->
|
||||
<div v-else class="ws-chat-collapsed">
|
||||
<button class="ws-chat-expand-btn" title="Expand chat" @click="expand">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
<ChevronUp :size="16" />
|
||||
</button>
|
||||
<div class="ws-chat-input-row">
|
||||
<ChatInputBar
|
||||
@@ -264,7 +265,7 @@ defineExpose({ prefill });
|
||||
}
|
||||
.ws-chat-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -339,7 +340,7 @@ defineExpose({ prefill });
|
||||
}
|
||||
.ws-chat-history-item.current {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ws-chat-history-title {
|
||||
display: block;
|
||||
|
||||
@@ -10,6 +10,7 @@ import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||
import TagInput from "@/components/TagInput.vue";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
import WordCount from "@/components/WordCount.vue";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
@@ -316,7 +317,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<button class="btn-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
||||
{{ creatingNote ? '…' : '+' }}
|
||||
</button>
|
||||
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote">✕</button>
|
||||
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="rail-title">Notes</span>
|
||||
@@ -332,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
type="search"
|
||||
aria-label="Search notes"
|
||||
/>
|
||||
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''">✕</button>
|
||||
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div v-if="listLoading" class="rail-state">Loading…</div>
|
||||
@@ -360,10 +361,10 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<button class="btn-confirm-delete" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
|
||||
{{ pendingDelete === note.id ? '…' : 'Delete?' }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)">✕</button>
|
||||
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
|
||||
</template>
|
||||
<button v-else class="btn-delete" title="Delete note" @click="requestDelete(note.id, $event)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
@@ -416,7 +417,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
|
||||
@click="tagSuggestions.applyTagSuggestion(tag)"
|
||||
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()">✕</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-row">
|
||||
@@ -429,7 +430,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
||||
</span>
|
||||
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []">✕</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
||||
@@ -479,7 +480,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -532,7 +533,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
padding: 1rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Note list */
|
||||
@@ -630,18 +630,20 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
}
|
||||
.note-row:hover .btn-delete { opacity: 1; }
|
||||
.btn-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||
.btn-delete:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-confirm-delete {
|
||||
background: none;
|
||||
border: 1px solid var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.btn-confirm-delete:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-cancel-delete {
|
||||
@@ -707,7 +709,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Editor UI */
|
||||
@@ -729,15 +730,18 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.unsaved { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
|
||||
|
||||
/* Moss action-primary per Hybrid */
|
||||
.btn-save {
|
||||
background: var(--color-primary);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.note-title-row {
|
||||
@@ -750,7 +754,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
color: var(--color-text);
|
||||
padding: 0;
|
||||
@@ -760,7 +764,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.note-title-input:focus { outline: none; }
|
||||
.note-title-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
@@ -843,7 +846,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
@@ -298,12 +299,12 @@ defineExpose({ reload: loadAll });
|
||||
</span>
|
||||
<template v-if="deleteConfirmPending">
|
||||
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
||||
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask">✕</button>
|
||||
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
|
||||
</template>
|
||||
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail">✕</button>
|
||||
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<h3 class="detail-title">{{ activeTask.title }}</h3>
|
||||
@@ -373,7 +374,7 @@ defineExpose({ reload: loadAll });
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-add {
|
||||
@@ -432,7 +433,7 @@ defineExpose({ reload: loadAll });
|
||||
.ms-group-header:hover { background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); }
|
||||
|
||||
.ms-chevron { font-size: 0.6rem; color: var(--color-text-muted); width: 0.8rem; }
|
||||
.ms-name { flex: 1; font-weight: 600; font-size: 0.8rem; }
|
||||
.ms-name { flex: 1; font-weight: 500; font-size: 0.8rem; }
|
||||
.ms-count { font-size: 0.72rem; color: var(--color-text-muted); background: var(--color-bg); border-radius: 10px; padding: 0 0.4rem; }
|
||||
|
||||
.ms-status {
|
||||
@@ -494,7 +495,7 @@ defineExpose({ reload: loadAll });
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); font-style: italic; }
|
||||
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); }
|
||||
|
||||
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--color-text-muted); }
|
||||
|
||||
@@ -522,7 +523,7 @@ defineExpose({ reload: loadAll });
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: none;
|
||||
@@ -575,19 +576,21 @@ defineExpose({ reload: loadAll });
|
||||
border-radius: 3px;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.btn-delete-task:hover { color: var(--color-danger, #e74c3c); }
|
||||
.btn-delete-task:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-delete-confirm {
|
||||
background: none;
|
||||
border: 1px solid var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.25rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-delete-confirm:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.btn-delete-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-delete-cancel {
|
||||
@@ -603,7 +606,7 @@ defineExpose({ reload: loadAll });
|
||||
.detail-title {
|
||||
padding: 0.75rem 0.75rem 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
@@ -672,7 +675,7 @@ defineExpose({ reload: loadAll });
|
||||
}
|
||||
.task-due.overdue {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Close detail button */
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface Note {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
description: string | null;
|
||||
consolidated_at: string | null;
|
||||
tags: string[];
|
||||
parent_id: number | null;
|
||||
parent_title?: string | null;
|
||||
|
||||
@@ -31,5 +31,7 @@ export interface NoteVersion {
|
||||
title: string;
|
||||
tags: string[];
|
||||
body?: string;
|
||||
pin_kind: "auto" | "manual" | null;
|
||||
pin_label: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { listEvents, updateEvent, type EventEntry } from "@/api/client";
|
||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { fmtTime, fmtDateTime, fmtDayLabel } from "@/utils/dateFormat";
|
||||
import { MapPin } from "lucide-vue-next";
|
||||
|
||||
const toast = useToastStore();
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||
@@ -333,9 +334,7 @@ const upcomingGrouped = computed(() => {
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="ev.location" class="upcoming-card-meta">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||
</svg>
|
||||
<MapPin :size="16" style="flex-shrink:0" />
|
||||
{{ ev.location }}
|
||||
</div>
|
||||
<div v-if="ev.description" class="upcoming-card-desc">{{ ev.description }}</div>
|
||||
@@ -367,9 +366,7 @@ const upcomingGrouped = computed(() => {
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="popover.location" class="popover-meta">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;margin-top:1px">
|
||||
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||
</svg>
|
||||
<MapPin :size="16" style="flex-shrink:0;margin-top:1px" />
|
||||
{{ popover.location }}
|
||||
</div>
|
||||
<div v-if="popover.description" class="popover-desc">{{ popover.description }}</div>
|
||||
@@ -429,23 +426,24 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.cal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
/* New event: Moss action-primary — workflow action, not a brand moment */
|
||||
.btn-new-event {
|
||||
background: var(--gradient-cta);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-new-event:hover { opacity: 0.88; }
|
||||
.btn-new-event:hover { background: var(--color-action-primary-hover); }
|
||||
|
||||
.fc-wrapper {
|
||||
background: var(--color-surface);
|
||||
@@ -461,8 +459,12 @@ const upcomingGrouped = computed(() => {
|
||||
font-family: inherit;
|
||||
}
|
||||
:deep(.fc-toolbar-title) {
|
||||
/* FullCalendar renders this as <h2>, which would otherwise pick up the
|
||||
theme.css h1/h2 → Fraunces rule. At 1.1rem it's below the doc's
|
||||
"Fraunces only at ≥18px" threshold, so explicit Inter. */
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
@@ -533,7 +535,7 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.picker-year-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
@@ -574,9 +576,9 @@ const upcomingGrouped = computed(() => {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
.picker-month.active {
|
||||
background: rgba(124,58,237,0.22);
|
||||
background: rgba(91, 74, 138,0.22);
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
|
||||
@@ -586,7 +588,7 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.upcoming-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -602,7 +604,7 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.upcoming-day-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -634,7 +636,7 @@ const upcomingGrouped = computed(() => {
|
||||
.upcoming-card-accent {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
background: var(--ev-color, #7c3aed);
|
||||
background: var(--ev-color, #5B4A8A);
|
||||
}
|
||||
|
||||
.upcoming-card-body {
|
||||
@@ -645,7 +647,7 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.upcoming-card-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -700,7 +702,7 @@ const upcomingGrouped = computed(() => {
|
||||
|
||||
.popover-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
@@ -744,7 +746,7 @@ const upcomingGrouped = computed(() => {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { apiGet } from "@/api/client";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
import { MoreVertical, Menu, Paperclip, Trash2 } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -314,9 +315,7 @@ onUnmounted(() => {
|
||||
aria-label="List actions"
|
||||
title="List actions"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
|
||||
</svg>
|
||||
<MoreVertical :size="16" />
|
||||
</button>
|
||||
<div v-if="sidebarKebabOpen" class="kebab-menu">
|
||||
<button
|
||||
@@ -339,6 +338,7 @@ onUnmounted(() => {
|
||||
:disabled="bulkDeleting"
|
||||
@click="bulkDelete"
|
||||
>
|
||||
<Trash2 :size="16" />
|
||||
{{ bulkDeleting ? '...' : bulkConfirm ? `Delete ${selectedIds.size}?` : `Delete (${selectedIds.size})` }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -388,11 +388,7 @@ onUnmounted(() => {
|
||||
<template v-if="store.currentConversation">
|
||||
<div class="chat-header">
|
||||
<button class="btn-sidebar-toggle hide-desktop" aria-label="Toggle sidebar" @click="toggleSidebar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
<Menu :size="24" />
|
||||
</button>
|
||||
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
|
||||
|
||||
@@ -433,7 +429,7 @@ onUnmounted(() => {
|
||||
@click="toggleContextSidebar"
|
||||
:title="contextSidebarOpen ? 'Hide context panel' : 'Show context panel'"
|
||||
>
|
||||
<span class="context-icon">📎</span>
|
||||
<Paperclip class="context-icon" :size="16" />
|
||||
<span class="context-label">Context</span>
|
||||
<span class="context-count-badge">{{ contextCount }}</span>
|
||||
</button>
|
||||
@@ -446,9 +442,7 @@ onUnmounted(() => {
|
||||
aria-label="Conversation actions"
|
||||
title="Conversation actions"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
|
||||
</svg>
|
||||
<MoreVertical :size="16" />
|
||||
</button>
|
||||
<div v-if="headerKebabOpen" class="kebab-menu kebab-menu--right">
|
||||
<button
|
||||
@@ -470,11 +464,7 @@ onUnmounted(() => {
|
||||
|
||||
<div v-else class="no-conversation">
|
||||
<button class="btn-sidebar-toggle no-conv-toggle hide-desktop" @click="toggleSidebar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
<Menu :size="24" />
|
||||
</button>
|
||||
<p>Select a conversation or start a new chat.</p>
|
||||
<button class="btn-new-conv" @click="newConversation">
|
||||
@@ -520,7 +510,7 @@ onUnmounted(() => {
|
||||
.btn-new-conv--full { width: 100%; }
|
||||
.btn-new-conv:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 0 14px rgba(129, 140, 248, 0.35);
|
||||
box-shadow: 0 0 14px rgba(91, 74, 138, 0.35);
|
||||
}
|
||||
|
||||
.sidebar-search-row {
|
||||
@@ -556,22 +546,29 @@ onUnmounted(() => {
|
||||
|
||||
.bulk-count { font-size: 0.75rem; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
.bulk-link {
|
||||
background: none; border: none; color: var(--color-primary);
|
||||
background: none; border: none; color: var(--color-text-secondary);
|
||||
font-size: 0.75rem; cursor: pointer; padding: 0; text-decoration: underline;
|
||||
}
|
||||
.bulk-link:hover { color: var(--color-text); }
|
||||
|
||||
/* Destructive action — Oxblood per Hybrid rule, paired with Trash2 icon */
|
||||
.bulk-delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
padding: 0.2rem 0.55rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bulk-delete-btn:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.bulk-delete-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.bulk-delete-btn.confirm { background: var(--color-danger, #e74c3c); color: #fff; }
|
||||
.bulk-delete-btn.confirm { background: var(--color-action-destructive-hover); color: #fff; border-color: var(--color-action-destructive-hover); }
|
||||
|
||||
.conv-checkbox {
|
||||
flex-shrink: 0;
|
||||
@@ -591,7 +588,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.conv-group-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -612,7 +609,7 @@ onUnmounted(() => {
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.conv-item:hover { background: rgba(99,102,241,0.05); border-left-color: var(--color-primary); }
|
||||
.conv-item:hover { background: rgba(91, 74, 138,0.05); border-left-color: var(--color-primary); }
|
||||
.conv-item.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
@@ -625,7 +622,7 @@ onUnmounted(() => {
|
||||
background: none; border: none; color: var(--color-text-muted);
|
||||
cursor: pointer; font-size: 1.2rem; padding: 0 0.25rem; line-height: 1;
|
||||
}
|
||||
.btn-delete-conv:hover { color: var(--color-danger, #e74c3c); }
|
||||
.btn-delete-conv:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
@@ -704,7 +701,7 @@ onUnmounted(() => {
|
||||
50% {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 0 6px rgba(99, 102, 241, 0.4);
|
||||
box-shadow: 0 0 6px rgba(91, 74, 138, 0.4);
|
||||
}
|
||||
}
|
||||
.scope-dot { font-size: 0.6rem; }
|
||||
@@ -732,7 +729,7 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-option:hover { background: var(--color-bg-secondary); }
|
||||
.scope-option.active { color: var(--color-primary); font-weight: 600; }
|
||||
.scope-option.active { color: var(--color-primary); font-weight: 500; }
|
||||
|
||||
/* Context toggle button (header) */
|
||||
.btn-context-toggle {
|
||||
@@ -759,7 +756,7 @@ onUnmounted(() => {
|
||||
.context-icon { font-size: 0.85rem; line-height: 1; }
|
||||
.context-count-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
background: var(--color-bg);
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from "lucide-vue-next";
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { forceSimulation, forceLink, forceManyBody, forceX, forceY, forceCollide, SimulationNodeDatum, SimulationLinkDatum, Simulation } from "d3-force";
|
||||
@@ -543,7 +544,7 @@ onUnmounted(() => {
|
||||
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}/edit` : `/notes/${peekNode.id}/edit`"
|
||||
class="peek-link"
|
||||
>Edit</router-link>
|
||||
<button class="peek-close" @click="closePeek" title="Close (Esc)">✕</button>
|
||||
<button class="peek-close" @click="closePeek" title="Close (Esc)"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -758,7 +759,7 @@ onUnmounted(() => {
|
||||
|
||||
.tooltip-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@@ -820,7 +821,7 @@ onUnmounted(() => {
|
||||
|
||||
.peek-type-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
@@ -859,7 +860,7 @@ onUnmounted(() => {
|
||||
margin: 0;
|
||||
padding: 0.75rem 0.75rem 0.4rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -897,7 +898,7 @@ onUnmounted(() => {
|
||||
|
||||
.peek-linked-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -932,7 +933,7 @@ onUnmounted(() => {
|
||||
|
||||
.peek-linked-type {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -569,7 +569,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1.75rem;
|
||||
box-shadow: 0 2px 16px rgba(124, 58, 237, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 2px 16px rgba(91, 74, 138, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.hero-top {
|
||||
display: flex;
|
||||
@@ -585,14 +585,14 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
}
|
||||
.hero-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
@@ -610,22 +610,22 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 6px rgba(124, 58, 237, 0.3);
|
||||
box-shadow: 0 1px 6px rgba(91, 74, 138, 0.3);
|
||||
transition: opacity 0.15s, box-shadow 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-workspace-hero:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 3px 14px rgba(124, 58, 237, 0.45);
|
||||
box-shadow: 0 3px 14px rgba(91, 74, 138, 0.45);
|
||||
}
|
||||
|
||||
.hero-milestones { margin-bottom: 0.75rem; }
|
||||
|
||||
.hero-section-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -688,7 +688,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.section-header h2 { margin: 0; font-size: 1rem; font-weight: 600; }
|
||||
.section-header h2 { margin: 0; font-size: 1rem; font-weight: 500; }
|
||||
.see-all { font-size: 0.85rem; color: var(--color-primary); text-decoration: none; }
|
||||
.see-all:hover { text-decoration: underline; }
|
||||
|
||||
@@ -718,7 +718,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
}
|
||||
.project-card-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
flex: 1;
|
||||
@@ -760,8 +760,8 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
font-weight: 500;
|
||||
}
|
||||
.urgency-in-progress {
|
||||
background: color-mix(in srgb, #7c3aed 15%, transparent);
|
||||
color: #7c3aed;
|
||||
background: color-mix(in srgb, #5B4A8A 15%, transparent);
|
||||
color: #5B4A8A;
|
||||
}
|
||||
.urgency-todo {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
@@ -771,7 +771,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
background: color-mix(in srgb, #22c55e 12%, transparent);
|
||||
color: #16a34a;
|
||||
}
|
||||
.urgency-loading { color: var(--color-text-muted); font-style: italic; }
|
||||
.urgency-loading { color: var(--color-text-muted); }
|
||||
|
||||
/* ─── Inbox ──────────────────────────────────────────────────── */
|
||||
.inbox-section {
|
||||
@@ -799,7 +799,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
.inbox-header h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
@@ -911,7 +911,7 @@ function formatUpcomingTime(event: EventEntry): string {
|
||||
}
|
||||
.upcoming-event-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import ChatPanel from '@/components/ChatPanel.vue'
|
||||
import WeatherCard from '@/components/WeatherCard.vue'
|
||||
import { RotateCcw } from 'lucide-vue-next'
|
||||
import {
|
||||
apiGet,
|
||||
apiPost,
|
||||
@@ -129,10 +130,15 @@ function formatEventTime(ev: EventEntry): string {
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const now = new Date()
|
||||
const end = new Date(now)
|
||||
// Window: today 00:00 → 14 days out. Matches the prep's framing — events
|
||||
// earlier today (already past) still surface here, grouped under "Today",
|
||||
// so the right rail aligns with what the prep references.
|
||||
const start = new Date()
|
||||
start.setHours(0, 0, 0, 0)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + 14)
|
||||
upcomingEvents.value = await listEvents(now.toISOString(), end.toISOString())
|
||||
end.setHours(23, 59, 59, 999)
|
||||
upcomingEvents.value = await listEvents(start.toISOString(), end.toISOString())
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
@@ -273,7 +279,9 @@ onMounted(async () => {
|
||||
:disabled="refreshingWeather"
|
||||
@click="refreshWeather"
|
||||
title="Refresh weather"
|
||||
>↻</button>
|
||||
>
|
||||
<RotateCcw :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<WeatherCard
|
||||
:weather="weatherData[selectedWeatherIdx]"
|
||||
@@ -336,9 +344,8 @@ onMounted(async () => {
|
||||
}
|
||||
.journal-header-left { display: flex; align-items: baseline; gap: 0.75rem; }
|
||||
.journal-title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
/* h1 inherits Fraunces from theme.css; weight 500 follows the doc's "two weights only" rule */
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
@@ -434,7 +441,7 @@ onMounted(async () => {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.events-section { padding: 0.75rem 1rem; border-top: 1px solid var(--color-border); }
|
||||
@@ -444,7 +451,7 @@ onMounted(async () => {
|
||||
.events-day-group { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.events-day-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
@@ -457,70 +464,19 @@ onMounted(async () => {
|
||||
flex-shrink: 0; margin-top: 5px;
|
||||
}
|
||||
.event-body { display: flex; flex-direction: column; gap: 0.05rem; min-width: 0; }
|
||||
.event-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.event-title { font-size: 0.82rem; font-weight: 500; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.event-time { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.event-loc { font-size: 0.7rem; color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.news-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.panel-label-row { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
|
||||
.news-count { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.panel-empty { font-size: 0.82rem; color: var(--color-text-muted); padding: 0.5rem 0; }
|
||||
|
||||
.news-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.news-card-meta { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.news-source { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-primary); }
|
||||
.news-date { font-size: 0.72rem; color: var(--color-text-muted); }
|
||||
.news-title { font-size: 0.88rem; font-weight: 600; color: var(--color-text); line-height: 1.35; text-decoration: none; margin: 0; }
|
||||
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
|
||||
.news-snippet {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.news-reactions { display: flex; gap: 0.3rem; margin-top: 0.15rem; }
|
||||
.reaction-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
.reaction-btn:hover { opacity: 1; border-color: var(--color-primary); }
|
||||
.reaction-btn.active { opacity: 1; border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.journal-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
|
||||
|
||||
@@ -7,6 +7,20 @@ import { useChatStore } from "@/stores/chat";
|
||||
import GraphView from "@/views/GraphView.vue";
|
||||
import ChatPanel from "@/components/ChatPanel.vue";
|
||||
import ChatInputBar from "@/components/ChatInputBar.vue";
|
||||
import {
|
||||
FileText,
|
||||
CheckSquare,
|
||||
User,
|
||||
MapPin,
|
||||
List,
|
||||
Search,
|
||||
Share2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const router = useRouter();
|
||||
const chatStore = useChatStore();
|
||||
@@ -426,23 +440,23 @@ onUnmounted(() => {
|
||||
</button>
|
||||
<div v-if="newNoteMenuOpen" class="new-note-menu">
|
||||
<button @click="createNew('note')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
<FileText :size="16" />
|
||||
Note
|
||||
</button>
|
||||
<button @click="createNew('task')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
<CheckSquare :size="16" />
|
||||
Task
|
||||
</button>
|
||||
<button @click="createNew('person')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
<User :size="16" />
|
||||
Person
|
||||
</button>
|
||||
<button @click="createNew('place')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<MapPin :size="16" />
|
||||
Place
|
||||
</button>
|
||||
<button @click="createNew('list')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||
<List :size="16" />
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
@@ -493,7 +507,7 @@ onUnmounted(() => {
|
||||
<!-- Toolbar -->
|
||||
<div class="knowledge-toolbar">
|
||||
<div class="search-wrap">
|
||||
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<Search class="search-icon" :size="16" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="onSearchInput"
|
||||
@@ -509,10 +523,7 @@ onUnmounted(() => {
|
||||
<option value="type">By type</option>
|
||||
</select>
|
||||
<button class="btn-graph" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<path d="m8.59 13.51 6.83 3.98M15.41 6.51l-6.82 3.98"/>
|
||||
</svg>
|
||||
<Share2 :size="16" />
|
||||
Graph
|
||||
</button>
|
||||
</div>
|
||||
@@ -630,12 +641,12 @@ onUnmounted(() => {
|
||||
@click="toggleGraphExpand"
|
||||
:title="graphExpanded ? 'Narrow panel' : 'Expand panel'"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline v-if="graphExpanded" points="15 18 9 12 15 6"/>
|
||||
<polyline v-else points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
<ChevronLeft v-if="graphExpanded" :size="16" />
|
||||
<ChevronRight v-else :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="toggleGraph" title="Close graph">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="toggleGraph" title="Close graph">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="graph-embed">
|
||||
@@ -656,12 +667,12 @@ onUnmounted(() => {
|
||||
@click="chatCollapsed = !chatCollapsed"
|
||||
:title="chatCollapsed ? 'Expand' : 'Collapse'"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline v-if="chatCollapsed" points="18 15 12 9 6 15"/>
|
||||
<polyline v-else points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
<ChevronUp v-if="chatCollapsed" :size="16" />
|
||||
<ChevronDown v-else :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="closeChat" title="Close chat">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="closeChat" title="Close chat">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -720,17 +731,17 @@ onUnmounted(() => {
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
border: 1px solid rgba(124, 58, 237, 0.2);
|
||||
background: rgba(91, 74, 138, 0.1);
|
||||
border: 1px solid rgba(91, 74, 138, 0.2);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.today-event-chip:hover { background: rgba(124, 58, 237, 0.18); }
|
||||
.today-event-chip:hover { background: rgba(91, 74, 138, 0.18); }
|
||||
.chip-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #7c3aed;
|
||||
background: #5B4A8A;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-date { color: var(--color-muted); font-size: 0.78rem; }
|
||||
@@ -775,18 +786,16 @@ onUnmounted(() => {
|
||||
content: '· · ·';
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: rgba(124, 58, 237, 0.3);
|
||||
color: rgba(91, 74, 138, 0.3);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.4em;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
.filter-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
/* ── New item button ─────────────────────────────────────── */
|
||||
@@ -806,7 +815,7 @@ onUnmounted(() => {
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.btn-new-note:hover { box-shadow: var(--glow-cta-hover); }
|
||||
@@ -891,7 +900,7 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-btn.active .filter-count {
|
||||
background: rgba(124, 58, 237, 0.2);
|
||||
background: rgba(91, 74, 138, 0.2);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.filter-tag { font-size: 0.78rem; }
|
||||
@@ -965,7 +974,7 @@ onUnmounted(() => {
|
||||
.btn-graph:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
|
||||
.btn-graph.active {
|
||||
background: var(--color-primary-wash);
|
||||
border-color: rgba(124, 58, 237, 0.35);
|
||||
border-color: rgba(91, 74, 138, 0.35);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@@ -998,12 +1007,12 @@ onUnmounted(() => {
|
||||
}
|
||||
.k-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px rgba(124, 58, 237, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(124, 58, 237, 0.35);
|
||||
box-shadow: 0 8px 28px rgba(91, 74, 138, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(91, 74, 138, 0.35);
|
||||
}
|
||||
|
||||
/* Type-specific card DNA */
|
||||
.k-card--note { border-color: rgba(124, 58, 237, 0.20); }
|
||||
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
|
||||
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
||||
.k-card--person { border-color: rgba(16, 185, 129, 0.18); }
|
||||
.k-card--place { border-color: rgba(245, 158, 11, 0.18); }
|
||||
@@ -1022,7 +1031,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.k-card--note::before {
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #7c3aed, #a78bfa);
|
||||
background: linear-gradient(90deg, #5B4A8A, #7A6DA8);
|
||||
}
|
||||
.k-card--task::before {
|
||||
right: 0;
|
||||
@@ -1056,11 +1065,11 @@ onUnmounted(() => {
|
||||
font-size: 0.68rem;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.badge--note { background: rgba(99,102,241,0.15); color: #a78bfa; }
|
||||
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
|
||||
.badge--person { background: rgba(16,185,129,0.15); color: #34d399; }
|
||||
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
|
||||
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
||||
@@ -1068,7 +1077,7 @@ onUnmounted(() => {
|
||||
|
||||
.k-card-body { flex: 1; padding-right: 40px; }
|
||||
.k-card-title {
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: 0.92rem;
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.3;
|
||||
@@ -1165,7 +1174,7 @@ onUnmounted(() => {
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-accent-warm); white-space: nowrap; opacity: 0.7; }
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-text-secondary); white-space: nowrap; opacity: 0.7; }
|
||||
|
||||
/* ── Task card ──────────────────────────────────────────── */
|
||||
.k-card-task {
|
||||
@@ -1182,7 +1191,7 @@ onUnmounted(() => {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
|
||||
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
|
||||
@@ -1193,7 +1202,7 @@ onUnmounted(() => {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
|
||||
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
|
||||
@@ -1201,7 +1210,7 @@ onUnmounted(() => {
|
||||
|
||||
.task-due {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-accent-warm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.task-overdue {
|
||||
color: var(--color-overdue);
|
||||
@@ -1223,9 +1232,8 @@ onUnmounted(() => {
|
||||
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
|
||||
.empty-narrator {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
color: var(--color-accent-warm);
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -1261,7 +1269,7 @@ onUnmounted(() => {
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1324,7 +1332,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.minichat-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
|
||||
@@ -21,6 +21,7 @@ import MilestoneSelector from "@/components/MilestoneSelector.vue";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { Trash2 } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -449,7 +450,9 @@ onUnmounted(() => assist.clearSelection());
|
||||
<button class="btn-save" @click="save" :disabled="saving">
|
||||
{{ saving ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">
|
||||
<Trash2 :size="16" /> Delete
|
||||
</button>
|
||||
<WordCount :body="body" />
|
||||
</div>
|
||||
<input
|
||||
@@ -892,7 +895,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
.stream-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.stream-preview {
|
||||
@@ -1013,7 +1015,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -1033,8 +1035,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
}
|
||||
.ef-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.ef-input {
|
||||
@@ -1067,7 +1068,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Note } from "@/types/note";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import TableOfContents from "@/components/TableOfContents.vue";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import { Clock, Pencil, Link as LinkIcon } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -238,12 +239,12 @@ async function convertToTask() {
|
||||
<h1 class="note-title">{{ store.currentNote.title || "Untitled" }}</h1>
|
||||
<p class="meta">
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
|
||||
<Clock :size="16" />
|
||||
Updated {{ relativeTime(store.currentNote.updated_at) }}
|
||||
</span>
|
||||
<span class="meta-sep" aria-hidden="true">·</span>
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
<Pencil :size="16" />
|
||||
Created {{ relativeTime(store.currentNote.created_at) }}
|
||||
</span>
|
||||
</p>
|
||||
@@ -265,7 +266,7 @@ async function convertToTask() {
|
||||
|
||||
<div v-if="backlinks.length" class="backlinks">
|
||||
<h3 class="backlinks-heading">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||
<LinkIcon :size="16" />
|
||||
Backlinks
|
||||
<span class="backlinks-count">{{ backlinks.length }}</span>
|
||||
</h3>
|
||||
@@ -344,41 +345,39 @@ async function convertToTask() {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Edit: Moss action-primary — switching from view to edit is operating
|
||||
the software, not a brand moment. */
|
||||
.btn-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--gradient-cta);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--glow-cta);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
box-shadow: var(--glow-cta-hover);
|
||||
opacity: 0.95;
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
/* Convert + Share: Bronze action-secondary — alternate paths */
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-action-secondary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-convert:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-convert:hover { background: var(--color-action-secondary-hover); }
|
||||
.btn-convert:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
@@ -386,21 +385,21 @@ async function convertToTask() {
|
||||
|
||||
.btn-share {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
.note-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
@@ -439,7 +438,7 @@ async function convertToTask() {
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -481,7 +480,7 @@ async function convertToTask() {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -283,18 +283,21 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Moss action-primary per Hybrid — list-view utility action,
|
||||
not a brand moment. Empty-state .empty-action below keeps accent. */
|
||||
.btn-primary {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
@@ -321,7 +324,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
.tab-btn.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-msg,
|
||||
@@ -336,7 +339,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
|
||||
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--color-text-muted); }
|
||||
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
|
||||
.empty-title { font-size: 1rem; font-weight: 600; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-title { font-size: 1rem; font-weight: 500; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
|
||||
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-primary); border-radius: var(--radius-sm); color: var(--color-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||
.empty-action:hover { background: var(--color-primary); color: #fff; }
|
||||
@@ -389,7 +392,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
}
|
||||
.project-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -398,7 +401,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
@@ -426,7 +429,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-label {
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -462,7 +465,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
flex-shrink: 0;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.milestone-bars {
|
||||
@@ -545,7 +548,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
}
|
||||
.modal-field label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.required {
|
||||
|
||||
@@ -5,6 +5,16 @@ import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Clock,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Check,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
interface Milestone {
|
||||
id: number;
|
||||
@@ -330,7 +340,7 @@ async function confirmDelete() {
|
||||
<router-link to="/projects" class="btn-back">← Projects</router-link>
|
||||
<div class="page-header-actions">
|
||||
<router-link v-if="project" :to="`/workspace/${project.id}`" class="btn-workspace">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
|
||||
<LayoutGrid :size="16" />
|
||||
Workspace
|
||||
</router-link>
|
||||
<button v-if="project" class="btn-share" @click="showShare = true">Share</button>
|
||||
@@ -359,7 +369,7 @@ async function confirmDelete() {
|
||||
</div>
|
||||
<p v-if="project.goal" class="project-goal">{{ project.goal }}</p>
|
||||
<p v-if="project.summary?.last_activity" class="project-activity">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
|
||||
<Clock :size="16" />
|
||||
Active {{ relativeTime(project.summary.last_activity) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -382,7 +392,7 @@ async function confirmDelete() {
|
||||
<span class="stat-label">done</span>
|
||||
</div>
|
||||
<div class="stat-chip stat-notes">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" aria-hidden="true" style="opacity:0.6"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
|
||||
<FileText :size="16" style="opacity:0.6" />
|
||||
<span class="stat-val">{{ project.summary.note_count }}</span>
|
||||
<span class="stat-label">notes</span>
|
||||
</div>
|
||||
@@ -420,7 +430,7 @@ async function confirmDelete() {
|
||||
<div class="tab-bar">
|
||||
<button :class="['tab-btn', { active: activeTab === 'tasks' }]" @click="activeTab = 'tasks'">
|
||||
Tasks
|
||||
<span v-if="project.summary" class="tab-count">{{ project.summary.task_counts.todo + project.summary.task_counts.in_progress + project.summary.task_counts.done }}</span>
|
||||
<span v-if="project.summary" class="tab-count">{{ (project.summary.task_counts.todo ?? 0) + (project.summary.task_counts.in_progress ?? 0) + (project.summary.task_counts.done ?? 0) }}</span>
|
||||
</button>
|
||||
<button :class="['tab-btn', { active: activeTab === 'notes' }]" @click="activeTab = 'notes'">
|
||||
Notes
|
||||
@@ -463,7 +473,8 @@ async function confirmDelete() {
|
||||
@click="group.milestone && renamingMilestoneId !== group.milestone.id && toggleMilestoneCollapse(group.milestone.id)"
|
||||
>
|
||||
<span class="ms-chevron" v-if="group.milestone">
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" fill="currentColor"><path :d="collapsedMilestones.has(group.milestone.id) ? 'M8 5l8 7-8 7V5z' : 'M5 8l7 8 7-8H5z'"/></svg>
|
||||
<ChevronRight v-if="collapsedMilestones.has(group.milestone.id)" :size="16" />
|
||||
<ChevronDown v-else :size="16" />
|
||||
</span>
|
||||
<template v-if="group.milestone && renamingMilestoneId === group.milestone.id">
|
||||
<input
|
||||
@@ -485,10 +496,10 @@ async function confirmDelete() {
|
||||
<span class="ms-pct">{{ group.milestone.pct }}%</span>
|
||||
<div class="ms-actions" @click.stop>
|
||||
<button class="ms-action-btn" title="Rename" @click="startRenameMilestone(group.milestone)">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
<Pencil :size="16" />
|
||||
</button>
|
||||
<button class="ms-action-btn ms-action-delete" title="Delete" @click="deletingMilestone = group.milestone">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -554,7 +565,7 @@ async function confirmDelete() {
|
||||
title="Mark as Done"
|
||||
:disabled="advancingTaskId === task.id"
|
||||
@click="advanceTaskStatus(task, $event)"
|
||||
>✓</button>
|
||||
><Check :size="16" /></button>
|
||||
</div>
|
||||
</router-link>
|
||||
<p v-if="!group.tasks.filter(t => t.status === 'in_progress').length" class="col-empty">No tasks</p>
|
||||
@@ -598,7 +609,7 @@ async function confirmDelete() {
|
||||
</div>
|
||||
<template v-else>
|
||||
<router-link v-for="note in notes" :key="note.id" :to="`/notes/${note.id}`" class="note-row">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" class="note-icon" aria-hidden="true"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
|
||||
<FileText class="note-icon" :size="16" />
|
||||
<span class="note-title">{{ note.title || "Untitled" }}</span>
|
||||
<span class="note-date">{{ relativeTime(note.updated_at) }}</span>
|
||||
</router-link>
|
||||
@@ -682,6 +693,9 @@ async function confirmDelete() {
|
||||
}
|
||||
.btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
/* Open Workspace: brand-moment CTA — keep accent gradient. Workspace is
|
||||
the project's "central feature moment" — entering the focused workspace
|
||||
is a Scribe-flavored action, not a plain operation. */
|
||||
.btn-workspace {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -692,38 +706,41 @@ async function confirmDelete() {
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--glow-cta);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; }
|
||||
|
||||
/* Share: Bronze action-secondary — alternate path */
|
||||
.btn-share {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* Delete project: Oxblood action-destructive ghost — outline form since
|
||||
the actual confirm modal carries the filled destructive treatment */
|
||||
.btn-danger-outline {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-danger-outline:hover { border-color: var(--color-danger, #e74c3c); color: var(--color-danger, #e74c3c); }
|
||||
.btn-danger-outline:hover { background: var(--color-action-destructive); color: #fff; }
|
||||
|
||||
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
||||
|
||||
@@ -739,7 +756,7 @@ async function confirmDelete() {
|
||||
.project-title-input {
|
||||
flex: 1;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
@@ -751,11 +768,11 @@ async function confirmDelete() {
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.project-title-input:focus { border-bottom-color: var(--color-primary); }
|
||||
.project-title-input::placeholder { color: var(--color-text-muted); font-weight: 400; font-style: italic; }
|
||||
.project-title-input::placeholder { color: var(--color-text-muted); font-weight: 400; }
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.18rem 0.55rem;
|
||||
@@ -763,7 +780,7 @@ async function confirmDelete() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); }
|
||||
.status-paused { background: color-mix(in srgb, var(--color-accent-warm) 14%, transparent); color: var(--color-accent-warm); border: 1px solid color-mix(in srgb, var(--color-accent-warm) 30%, transparent); }
|
||||
.status-paused { background: color-mix(in srgb, var(--color-warning) 14%, transparent); color: var(--color-warning); border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent); }
|
||||
.status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); }
|
||||
.status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); }
|
||||
|
||||
@@ -799,7 +816,7 @@ async function confirmDelete() {
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid;
|
||||
}
|
||||
.stat-val { font-weight: 700; font-size: 0.9rem; }
|
||||
.stat-val { font-weight: 500; font-size: 0.9rem; }
|
||||
.stat-label { color: inherit; opacity: 0.8; }
|
||||
|
||||
.stat-dot {
|
||||
@@ -840,7 +857,7 @@ async function confirmDelete() {
|
||||
.panel-heading {
|
||||
margin: 0 0 0.1rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -848,7 +865,7 @@ async function confirmDelete() {
|
||||
.edit-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.edit-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
@@ -867,21 +884,21 @@ async function confirmDelete() {
|
||||
.edit-input:focus, .edit-textarea:focus, .edit-select:focus { outline: none; border-color: var(--color-primary); }
|
||||
.edit-textarea { resize: vertical; }
|
||||
|
||||
/* Save panel: Moss action-primary per Hybrid rule */
|
||||
.btn-save-panel {
|
||||
padding: 0.45rem 0.9rem;
|
||||
background: var(--gradient-cta);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
box-shadow: var(--glow-soft);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save-panel:hover:not(:disabled) { box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4); opacity: 0.95; }
|
||||
.btn-save-panel:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-save-panel:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
/* ── Content area ────────────────────────────────────────────── */
|
||||
@@ -908,10 +925,10 @@ async function confirmDelete() {
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.tab-btn:hover { color: var(--color-primary); }
|
||||
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 600; }
|
||||
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 500; }
|
||||
.tab-count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
@@ -953,27 +970,32 @@ async function confirmDelete() {
|
||||
font-family: inherit;
|
||||
}
|
||||
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
|
||||
.btn-ms-confirm {
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: var(--color-primary);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-ms-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-ms-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-ms-cancel {
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-ms-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* ── Milestone group ─────────────────────────────────────────── */
|
||||
.milestone-group {
|
||||
@@ -996,7 +1018,7 @@ async function confirmDelete() {
|
||||
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
|
||||
|
||||
.ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
.ms-name { font-weight: 600; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ms-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ms-count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
@@ -1005,7 +1027,7 @@ async function confirmDelete() {
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.4rem;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ms-progress-track {
|
||||
width: 72px;
|
||||
@@ -1021,7 +1043,7 @@ async function confirmDelete() {
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.ms-pct { font-size: 0.72rem; color: var(--color-text-secondary); flex-shrink: 0; min-width: 2.4rem; text-align: right; font-weight: 600; }
|
||||
.ms-pct { font-size: 0.72rem; color: var(--color-text-secondary); flex-shrink: 0; min-width: 2.4rem; text-align: right; font-weight: 500; }
|
||||
|
||||
.ms-actions { display: flex; gap: 0.15rem; margin-left: 0.2rem; opacity: 0; transition: opacity 0.15s; }
|
||||
.milestone-header:hover .ms-actions { opacity: 1; }
|
||||
@@ -1050,7 +1072,7 @@ async function confirmDelete() {
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -1081,7 +1103,7 @@ async function confirmDelete() {
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
@@ -1096,7 +1118,7 @@ async function confirmDelete() {
|
||||
padding: 0.05rem 0.4rem;
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.col-add-btn {
|
||||
display: inline-flex;
|
||||
@@ -1242,8 +1264,8 @@ async function confirmDelete() {
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover { background: var(--color-bg); }
|
||||
.modal-btn-danger { background: var(--color-danger, #e74c3c); border-color: var(--color-danger, #e74c3c); color: #fff; }
|
||||
.modal-btn-danger:hover { opacity: 0.9; }
|
||||
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; }
|
||||
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
|
||||
|
||||
/* ── Skeleton ────────────────────────────────────────────────── */
|
||||
@keyframes skel-shine { to { background-position: 200% center; } }
|
||||
|
||||
+434
-220
File diff suppressed because it is too large
Load Diff
@@ -248,7 +248,6 @@ onMounted(async () => {
|
||||
|
||||
.empty-msg {
|
||||
color: var(--color-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.88rem;
|
||||
margin: 0;
|
||||
padding: 1rem 0;
|
||||
|
||||
@@ -25,6 +25,7 @@ import DiffView from "@/components/DiffView.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
||||
import RecurrenceEditor from "@/components/RecurrenceEditor.vue";
|
||||
import { Trash2 } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -34,6 +35,8 @@ const toast = useToastStore();
|
||||
|
||||
const title = ref("");
|
||||
const body = ref("");
|
||||
const description = ref("");
|
||||
const consolidatedAt = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const status = ref<TaskStatus>("todo");
|
||||
const priority = ref<TaskPriority>("none");
|
||||
@@ -106,6 +109,29 @@ async function toggleSubTask(sub: SubTask) {
|
||||
}
|
||||
const showPreview = ref(false);
|
||||
const sidebarOpen = ref(true);
|
||||
const reconsolidating = ref(false);
|
||||
|
||||
// Body is machine-maintained once a consolidation pass has run. The editor
|
||||
// is gated to read-only in that state; the user can re-consolidate or rely
|
||||
// on log_work entries flowing into the next auto pass.
|
||||
const isBodyAutoMaintained = computed(() => consolidatedAt.value !== null);
|
||||
|
||||
async function reconsolidate() {
|
||||
if (!taskId.value || reconsolidating.value) return;
|
||||
reconsolidating.value = true;
|
||||
try {
|
||||
const { consolidateTask } = await import("@/api/client");
|
||||
const updated = await consolidateTask(taskId.value);
|
||||
body.value = updated.body;
|
||||
consolidatedAt.value = updated.consolidated_at ?? null;
|
||||
savedBody = body.value;
|
||||
toast.show("Task summary refreshed");
|
||||
} catch {
|
||||
toast.show("Failed to re-consolidate", "error");
|
||||
} finally {
|
||||
reconsolidating.value = false;
|
||||
}
|
||||
}
|
||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||
const titleRef = ref<HTMLInputElement | null>(null);
|
||||
const tiptapEditor = computed<Editor | null>(() => {
|
||||
@@ -185,6 +211,7 @@ const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTa
|
||||
|
||||
let savedTitle = "";
|
||||
let savedBody = "";
|
||||
let savedDescription = "";
|
||||
let savedTags: string[] = [];
|
||||
let savedStatus: TaskStatus = "todo";
|
||||
let savedPriority: TaskPriority = "none";
|
||||
@@ -197,6 +224,7 @@ function markDirty() {
|
||||
dirty.value =
|
||||
title.value !== savedTitle ||
|
||||
body.value !== savedBody ||
|
||||
description.value !== savedDescription ||
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||
status.value !== savedStatus ||
|
||||
priority.value !== savedPriority ||
|
||||
@@ -269,6 +297,8 @@ onMounted(async () => {
|
||||
if (store.currentTask) {
|
||||
title.value = store.currentTask.title;
|
||||
body.value = store.currentTask.body;
|
||||
description.value = store.currentTask.description ?? "";
|
||||
consolidatedAt.value = store.currentTask.consolidated_at ?? null;
|
||||
tags.value = [...(store.currentTask.tags || [])];
|
||||
status.value = store.currentTask.status as TaskStatus;
|
||||
priority.value = store.currentTask.priority as TaskPriority;
|
||||
@@ -285,6 +315,7 @@ onMounted(async () => {
|
||||
recurrenceRule.value = noteTask.recurrence_rule ?? null;
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedDescription = description.value;
|
||||
savedTags = [...tags.value];
|
||||
savedStatus = status.value;
|
||||
savedPriority = priority.value;
|
||||
@@ -311,6 +342,7 @@ async function save() {
|
||||
const data = {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
description: description.value,
|
||||
tags: tags.value,
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
@@ -324,6 +356,7 @@ async function save() {
|
||||
await store.updateTask(taskId.value!, data);
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedDescription = description.value;
|
||||
savedTags = [...tags.value];
|
||||
savedStatus = status.value;
|
||||
savedPriority = priority.value;
|
||||
@@ -376,6 +409,7 @@ async function doAutoSave() {
|
||||
await store.updateTask(taskId.value!, {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
description: description.value,
|
||||
tags: tags.value,
|
||||
status: status.value,
|
||||
priority: priority.value,
|
||||
@@ -387,6 +421,7 @@ async function doAutoSave() {
|
||||
} as Record<string, unknown>);
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedDescription = description.value;
|
||||
savedTags = [...tags.value];
|
||||
savedStatus = status.value;
|
||||
savedPriority = priority.value;
|
||||
@@ -414,7 +449,9 @@ useEditorGuards(dirty, save);
|
||||
<button class="btn-save" @click="save" :disabled="saving">
|
||||
{{ saving ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
|
||||
<button v-if="isEditing" class="btn-delete" @click="remove">
|
||||
<Trash2 :size="16" /> Delete
|
||||
</button>
|
||||
<WordCount :body="body" />
|
||||
</div>
|
||||
<input
|
||||
@@ -428,7 +465,19 @@ useEditorGuards(dirty, save);
|
||||
@keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()"
|
||||
/>
|
||||
|
||||
</div><!-- /editor-header: title only -->
|
||||
<div class="task-goal">
|
||||
<label for="task-description" class="task-goal-label">Goal</label>
|
||||
<textarea
|
||||
id="task-description"
|
||||
v-model="description"
|
||||
placeholder="What are we trying to do here? (read-only context for the auto-summary)"
|
||||
rows="2"
|
||||
class="task-goal-input"
|
||||
@input="markDirty"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
</div><!-- /editor-header: title + goal -->
|
||||
|
||||
<!-- Two-column body: main (editor+log) | sidebar (metadata) -->
|
||||
<div class="task-body">
|
||||
@@ -436,13 +485,33 @@ useEditorGuards(dirty, save);
|
||||
<!-- ── Main column ─────────────────────────────────────────── -->
|
||||
<div class="task-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
||||
|
||||
<!-- Write / Preview tabs + toolbar sit above the editor -->
|
||||
<!-- Auto-summary banner when consolidation has run on this task. -->
|
||||
<div v-if="isBodyAutoMaintained" class="auto-summary-banner-editor">
|
||||
<span class="auto-summary-icon" aria-hidden="true">✦</span>
|
||||
Auto-summarized from work logs.
|
||||
<button
|
||||
type="button"
|
||||
class="btn-reconsolidate"
|
||||
:disabled="reconsolidating"
|
||||
@click="reconsolidate"
|
||||
>
|
||||
{{ reconsolidating ? "Re-consolidating…" : "Re-consolidate" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Write / Preview tabs + toolbar sit above the editor.
|
||||
Write tab hidden when body is machine-maintained — use Re-consolidate
|
||||
or edit work logs instead. -->
|
||||
<div class="body-tabs-row">
|
||||
<div class="editor-tabs">
|
||||
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
|
||||
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
|
||||
<button
|
||||
v-if="!isBodyAutoMaintained"
|
||||
:class="['tab', { active: !showPreview }]"
|
||||
@click="showPreview = false"
|
||||
>Write</button>
|
||||
<button :class="['tab', { active: showPreview || isBodyAutoMaintained }]" @click="showPreview = true">Preview</button>
|
||||
</div>
|
||||
<MarkdownToolbar v-show="!showPreview && assist.state.value === 'idle'" :editor="tiptapEditor" />
|
||||
<MarkdownToolbar v-show="!showPreview && !isBodyAutoMaintained && assist.state.value === 'idle'" :editor="tiptapEditor" />
|
||||
</div>
|
||||
|
||||
<!-- Streaming preview -->
|
||||
@@ -456,9 +525,14 @@ useEditorGuards(dirty, save);
|
||||
<DiffView :diff="assist.diff.value" class="main-diff" />
|
||||
</template>
|
||||
|
||||
<!-- Normal: editor or preview -->
|
||||
<!-- Normal: editor or preview. When body is machine-maintained,
|
||||
always render the preview (read-only) — never the editor. -->
|
||||
<template v-else>
|
||||
<div v-show="!showPreview" class="body-editor-wrap">
|
||||
<div
|
||||
v-if="!isBodyAutoMaintained"
|
||||
v-show="!showPreview"
|
||||
class="body-editor-wrap"
|
||||
>
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
@@ -468,7 +542,11 @@ useEditorGuards(dirty, save);
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="showPreview" class="preview-pane prose" v-html="renderedPreview" />
|
||||
<div
|
||||
v-show="showPreview || isBodyAutoMaintained"
|
||||
class="preview-pane prose"
|
||||
v-html="renderedPreview"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
|
||||
@@ -737,12 +815,19 @@ useEditorGuards(dirty, save);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* .task-main is a flex column; without flex-shrink: 0, long body content
|
||||
gets squeezed back to min-height and overflows visibly on top of siblings. */
|
||||
.body-editor-wrap,
|
||||
.body-log {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.body-editor-wrap {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.body-log {
|
||||
/* TaskLogSection already has its own border/bg */
|
||||
:deep(.preview-pane) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Right sidebar: metadata fields */
|
||||
@@ -811,7 +896,7 @@ useEditorGuards(dirty, save);
|
||||
}
|
||||
.subtasks-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -889,7 +974,6 @@ useEditorGuards(dirty, save);
|
||||
.stream-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.stream-preview {
|
||||
border: 1px solid var(--color-input-border);
|
||||
@@ -911,7 +995,7 @@ useEditorGuards(dirty, save);
|
||||
}
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -962,4 +1046,77 @@ useEditorGuards(dirty, save);
|
||||
overflow-y: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Goal (description) input ─────────────────────────────────────────────── */
|
||||
.task-goal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
}
|
||||
.task-goal-label {
|
||||
font-family: var(--font-display, "Fraunces", serif);
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
.task-goal-input {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 2.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text, inherit);
|
||||
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
.task-goal-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
|
||||
/* ── Auto-summary banner + re-consolidate button ─────────────────────────── */
|
||||
.auto-summary-banner-editor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
.auto-summary-banner-editor .auto-summary-icon {
|
||||
color: var(--color-primary, #6366f1);
|
||||
font-style: normal;
|
||||
}
|
||||
.btn-reconsolidate {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
font-style: normal;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.btn-reconsolidate:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-reconsolidate:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@ import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import TableOfContents from "@/components/TableOfContents.vue";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import { Clock, Pencil, Link as LinkIcon } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -314,12 +315,12 @@ const subTaskProgress = computed(() => {
|
||||
<h1 class="task-title">{{ store.currentTask.title || "Untitled" }}</h1>
|
||||
<p class="meta">
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
|
||||
<Clock :size="16" />
|
||||
Updated {{ relativeTime(store.currentTask.updated_at) }}
|
||||
</span>
|
||||
<span class="meta-sep" aria-hidden="true">·</span>
|
||||
<span class="meta-item">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
<Pencil :size="16" />
|
||||
Created {{ relativeTime(store.currentTask.created_at) }}
|
||||
</span>
|
||||
</p>
|
||||
@@ -356,6 +357,22 @@ const subTaskProgress = computed(() => {
|
||||
@click="onTagClick"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="store.currentTask.description"
|
||||
class="task-goal-display"
|
||||
>
|
||||
<h3 class="goal-label">Goal</h3>
|
||||
<p class="goal-text">{{ store.currentTask.description }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.currentTask.consolidated_at"
|
||||
class="auto-summary-banner"
|
||||
>
|
||||
<span class="auto-summary-icon" aria-hidden="true">✦</span>
|
||||
Auto-summarized from work logs.
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="body prose"
|
||||
v-html="renderedBody"
|
||||
@@ -395,7 +412,7 @@ const subTaskProgress = computed(() => {
|
||||
|
||||
<div v-if="backlinks.length" class="backlinks">
|
||||
<h3 class="backlinks-heading">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||
<LinkIcon :size="16" />
|
||||
Backlinks
|
||||
<span class="backlinks-count">{{ backlinks.length }}</span>
|
||||
</h3>
|
||||
@@ -474,55 +491,41 @@ const subTaskProgress = computed(() => {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-edit {
|
||||
/* Edit + Advance: Moss action-primary — both are "operating the software"
|
||||
workflow actions, not brand moments. */
|
||||
.btn-edit,
|
||||
.btn-advance {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--gradient-cta);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--glow-cta);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
box-shadow: var(--glow-cta-hover);
|
||||
opacity: 0.95;
|
||||
.btn-edit:hover,
|
||||
.btn-advance:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-advance {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--gradient-cta);
|
||||
/* Convert + Share: Bronze action-secondary — alternate paths */
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-advance:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-convert:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-convert:hover { background: var(--color-action-secondary-hover); }
|
||||
.btn-convert:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
@@ -530,21 +533,21 @@ const subTaskProgress = computed(() => {
|
||||
|
||||
.btn-share {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
.task-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
@@ -579,7 +582,7 @@ const subTaskProgress = computed(() => {
|
||||
}
|
||||
.due-date.overdue {
|
||||
color: var(--color-overdue);
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.task-meta-row {
|
||||
display: flex;
|
||||
@@ -617,7 +620,7 @@ const subTaskProgress = computed(() => {
|
||||
.subtasks-title {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
.subtasks-progress {
|
||||
font-size: 0.8rem;
|
||||
@@ -717,7 +720,7 @@ const subTaskProgress = computed(() => {
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
@@ -759,7 +762,7 @@ const subTaskProgress = computed(() => {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
@@ -820,4 +823,42 @@ const subTaskProgress = computed(() => {
|
||||
.skel-line { height: 0.9rem; }
|
||||
.skel-line--short { width: 50%; }
|
||||
.skel-line--medium { width: 78%; }
|
||||
|
||||
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
|
||||
.task-goal-display {
|
||||
border-left: 2px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
padding: 0.4rem 0 0.4rem 0.9rem;
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.goal-label {
|
||||
font-family: var(--font-display, "Fraunces", serif);
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.goal-text {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text, inherit);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.auto-summary-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.auto-summary-icon {
|
||||
color: var(--color-primary, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -477,7 +477,6 @@ function formatDate(iso: string): string {
|
||||
.you-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.btn-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
|
||||
@@ -183,7 +183,6 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ws-panel-toggles {
|
||||
|
||||
@@ -331,6 +331,12 @@ def create_app() -> Quart:
|
||||
from fabledassistant.services.event_scheduler import start_event_scheduler
|
||||
start_event_scheduler(asyncio.get_running_loop())
|
||||
|
||||
# Start version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
|
||||
from fabledassistant.services.version_pinning_scheduler import (
|
||||
start_version_pinning_scheduler,
|
||||
)
|
||||
start_version_pinning_scheduler(asyncio.get_running_loop())
|
||||
|
||||
# Voice model loading (enabled via Admin → Config in the UI, or VOICE_ENABLED env var)
|
||||
from fabledassistant.services.stt import load_stt_model
|
||||
from fabledassistant.services.tts import load_tts_model
|
||||
@@ -343,6 +349,10 @@ def create_app() -> Quart:
|
||||
stop_journal_scheduler()
|
||||
from fabledassistant.services.event_scheduler import stop_event_scheduler
|
||||
stop_event_scheduler()
|
||||
from fabledassistant.services.version_pinning_scheduler import (
|
||||
stop_version_pinning_scheduler,
|
||||
)
|
||||
stop_version_pinning_scheduler()
|
||||
|
||||
@app.route("/")
|
||||
async def serve_index():
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
@@ -20,7 +20,11 @@ class Event(Base):
|
||||
uid: Mapped[str] = mapped_column(Text)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
end_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Duration in minutes; NULL = point event with no end specified.
|
||||
# Replaces the prior `end_dt` column (Fable #160 / migration 0043).
|
||||
# The DB has a CHECK constraint that this is NULL or >= 0, so an
|
||||
# event whose end is before its start is structurally inexpressible.
|
||||
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
location: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -38,7 +42,21 @@ class Event(Base):
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
@property
|
||||
def end_dt(self) -> datetime | None:
|
||||
"""Derived end datetime: ``start_dt + duration_minutes``.
|
||||
|
||||
Returns ``None`` for point events (``duration_minutes is None``).
|
||||
Computed at access time rather than stored — a stored end was
|
||||
the source of the "end before start" corruption that motivated
|
||||
this redesign.
|
||||
"""
|
||||
if self.duration_minutes is None:
|
||||
return None
|
||||
return self.start_dt + timedelta(minutes=self.duration_minutes)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
end_dt = self.end_dt
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
@@ -47,7 +65,8 @@ class Event(Base):
|
||||
"project_id": self.project_id,
|
||||
"title": self.title,
|
||||
"start_dt": self.start_dt.isoformat() if self.start_dt else None,
|
||||
"end_dt": self.end_dt.isoformat() if self.end_dt else None,
|
||||
"end_dt": end_dt.isoformat() if end_dt else None,
|
||||
"duration_minutes": self.duration_minutes,
|
||||
"all_day": self.all_day,
|
||||
"description": self.description,
|
||||
"location": self.location,
|
||||
|
||||
@@ -32,6 +32,10 @@ class Note(Base, TimestampMixin):
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
consolidated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
@@ -81,6 +85,10 @@ class Note(Base, TimestampMixin):
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"body": self.body,
|
||||
"description": self.description,
|
||||
"consolidated_at": (
|
||||
self.consolidated_at.isoformat() if self.consolidated_at else None
|
||||
),
|
||||
"tags": self.tags or [],
|
||||
"parent_id": self.parent_id,
|
||||
"project_id": self.project_id,
|
||||
|
||||
@@ -14,6 +14,8 @@ class NoteVersion(Base, CreatedAtMixin):
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
title: Mapped[str] = mapped_column(Text, default="")
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
|
||||
pin_kind: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
pin_label: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
def to_dict(self, include_body: bool = True) -> dict:
|
||||
d: dict = {
|
||||
@@ -22,6 +24,8 @@ class NoteVersion(Base, CreatedAtMixin):
|
||||
"user_id": self.user_id,
|
||||
"title": self.title,
|
||||
"tags": self.tags or [],
|
||||
"pin_kind": self.pin_kind,
|
||||
"pin_label": self.pin_label,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
if include_body:
|
||||
|
||||
@@ -54,19 +54,23 @@ async def create_event():
|
||||
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid datetime format"}), 400
|
||||
event = await events_svc.create_event(
|
||||
user_id=_get_current_user_id(),
|
||||
title=data["title"],
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
all_day=data.get("all_day", False),
|
||||
description=data.get("description", ""),
|
||||
location=data.get("location", ""),
|
||||
color=data.get("color", ""),
|
||||
recurrence=data.get("recurrence"),
|
||||
project_id=data.get("project_id"),
|
||||
reminder_minutes=data.get("reminder_minutes"),
|
||||
)
|
||||
try:
|
||||
event = await events_svc.create_event(
|
||||
user_id=_get_current_user_id(),
|
||||
title=data["title"],
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
duration_minutes=data.get("duration_minutes"),
|
||||
all_day=data.get("all_day", False),
|
||||
description=data.get("description", ""),
|
||||
location=data.get("location", ""),
|
||||
color=data.get("color", ""),
|
||||
recurrence=data.get("recurrence"),
|
||||
project_id=data.get("project_id"),
|
||||
reminder_minutes=data.get("reminder_minutes"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
return jsonify(event.to_dict()), 201
|
||||
|
||||
|
||||
@@ -93,7 +97,7 @@ async def update_event(event_id: int):
|
||||
for bool_field in ("all_day",):
|
||||
if bool_field in data:
|
||||
fields[bool_field] = data[bool_field]
|
||||
for int_field in ("project_id", "reminder_minutes"):
|
||||
for int_field in ("project_id", "reminder_minutes", "duration_minutes"):
|
||||
if int_field in data:
|
||||
fields[int_field] = data[int_field]
|
||||
for dt_field in ("start_dt", "end_dt"):
|
||||
@@ -106,11 +110,14 @@ async def update_event(event_id: int):
|
||||
fields[dt_field] = _parse_dt(data[dt_field])
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
|
||||
event = await events_svc.update_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
**fields,
|
||||
)
|
||||
try:
|
||||
event = await events_svc.update_event(
|
||||
user_id=_get_current_user_id(),
|
||||
event_id=event_id,
|
||||
**fields,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if event is None:
|
||||
return jsonify({"error": "Event not found"}), 404
|
||||
return jsonify(event.to_dict())
|
||||
|
||||
@@ -8,6 +8,7 @@ The ambient endpoints read locations + temp_unit + topic preferences from the
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
@@ -65,6 +66,17 @@ async def _resolve_config(user_id: int) -> dict:
|
||||
return {**DEFAULT_JOURNAL_CONFIG, **config}
|
||||
|
||||
|
||||
def _valid_location_keys(cfg: dict) -> set[str]:
|
||||
"""Keys in ``cfg.locations`` that have a usable lat/lon. Anything else
|
||||
(orphaned cache rows, locations the user typed but didn't geocode) is
|
||||
excluded so it can't render as a fake site in the UI."""
|
||||
locations = cfg.get("locations") or {}
|
||||
return {
|
||||
key for key, loc in locations.items()
|
||||
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
|
||||
}
|
||||
|
||||
|
||||
@journal_bp.get("/config")
|
||||
@login_required
|
||||
async def get_config():
|
||||
@@ -81,9 +93,41 @@ async def put_config():
|
||||
return jsonify({"error": "config must be an object"}), 400
|
||||
await set_setting(user_id, "journal_config", json.dumps(body))
|
||||
await update_user_schedule(user_id)
|
||||
|
||||
# Trigger a background weather refresh for any newly-saved location with
|
||||
# valid lat/lon. Without this, the cache row for the location doesn't
|
||||
# exist (or stays stale) until the user clicks the manual refresh button,
|
||||
# so the journal weather panel renders empty for newly-entered sites.
|
||||
valid_locs = [
|
||||
(key, loc)
|
||||
for key, loc in (body.get("locations") or {}).items()
|
||||
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
|
||||
]
|
||||
if valid_locs:
|
||||
asyncio.create_task(_refresh_locations_in_background(user_id, valid_locs))
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
async def _refresh_locations_in_background(
|
||||
user_id: int, locations: list[tuple[str, dict]]
|
||||
) -> None:
|
||||
for key, loc in locations:
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Post-save weather refresh failed for user %d / %s",
|
||||
user_id, key, exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@journal_bp.get("/today")
|
||||
@login_required
|
||||
async def get_today():
|
||||
@@ -222,12 +266,52 @@ async def _journal_temp_unit(user_id: int) -> str:
|
||||
# ── Weather ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_STALE_THRESHOLD_SECONDS = 4 * 3600 # 4 hours — start refreshing well before the 7-day forecast window slides past today
|
||||
|
||||
|
||||
def _is_stale(cache_row) -> bool:
|
||||
if cache_row is None or cache_row.fetched_at is None:
|
||||
return True
|
||||
age = (datetime.datetime.now(datetime.timezone.utc) - cache_row.fetched_at).total_seconds()
|
||||
return age > _STALE_THRESHOLD_SECONDS
|
||||
|
||||
|
||||
async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> None:
|
||||
"""Best-effort refresh of stale cache rows. Silently no-ops if the user's
|
||||
config has no usable lat/lon for a given location_key."""
|
||||
cfg = await _resolve_config(user_id)
|
||||
locations = cfg.get("locations") or {}
|
||||
for key in stale_keys:
|
||||
loc = locations.get(key)
|
||||
if not loc or not loc.get("lat") or not loc.get("lon"):
|
||||
continue
|
||||
try:
|
||||
await weather_svc.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Background weather refresh failed for user %d / %s", user_id, key, exc_info=True)
|
||||
|
||||
|
||||
@journal_bp.get("/weather")
|
||||
@login_required
|
||||
async def get_weather():
|
||||
user_id = get_current_user_id()
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id)
|
||||
cfg = await _resolve_config(user_id)
|
||||
valid_keys = _valid_location_keys(cfg)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys)
|
||||
temp_unit = await _journal_temp_unit(user_id)
|
||||
|
||||
# Kick off a best-effort background refresh for stale rows so the next page
|
||||
# load gets fresh data; we still serve whatever's currently cached now.
|
||||
stale_keys = {row.location_key for row in rows if _is_stale(row)}
|
||||
if stale_keys:
|
||||
asyncio.create_task(_refresh_stale_in_background(user_id, stale_keys))
|
||||
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
@@ -279,7 +363,8 @@ async def refresh_weather():
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id)
|
||||
valid_keys = _valid_location_keys(cfg)
|
||||
rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys)
|
||||
cards = [
|
||||
card for row in rows
|
||||
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
|
||||
|
||||
@@ -112,6 +112,7 @@ async def create_note_route():
|
||||
uid,
|
||||
title=data.get("title", ""),
|
||||
body=body,
|
||||
description=data.get("description"),
|
||||
tags=tags,
|
||||
parent_id=data.get("parent_id"),
|
||||
project_id=project_id,
|
||||
@@ -215,7 +216,7 @@ async def update_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
if "metadata" in data:
|
||||
@@ -250,7 +251,7 @@ async def patch_note_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
fields = {}
|
||||
for key in ("title", "body", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
if "metadata" in data:
|
||||
@@ -552,6 +553,39 @@ async def get_version_route(note_id: int, version_id: int):
|
||||
return jsonify(version.to_dict(include_body=True))
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>/versions/<int:version_id>/pin", methods=["POST"])
|
||||
@login_required
|
||||
async def pin_version_route(note_id: int, version_id: int):
|
||||
"""Mark a version as manually pinned. Body: {"label": str | null}."""
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
label = data.get("label")
|
||||
if label is not None and not isinstance(label, str):
|
||||
return jsonify({"error": "label must be a string or null"}), 400
|
||||
from fabledassistant.services.version_pinning import pin_version
|
||||
try:
|
||||
version = await pin_version(uid, note_id, version_id, label=label)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if version is None:
|
||||
return not_found("Version")
|
||||
return jsonify(version.to_dict(include_body=False))
|
||||
|
||||
|
||||
@notes_bp.route(
|
||||
"/<int:note_id>/versions/<int:version_id>/pin", methods=["DELETE"],
|
||||
)
|
||||
@login_required
|
||||
async def unpin_version_route(note_id: int, version_id: int):
|
||||
"""Downgrade a manually-pinned version back to rolling."""
|
||||
uid = get_current_user_id()
|
||||
from fabledassistant.services.version_pinning import unpin_version
|
||||
version = await unpin_version(uid, note_id, version_id)
|
||||
if version is None:
|
||||
return not_found("Version")
|
||||
return jsonify(version.to_dict(include_body=False))
|
||||
|
||||
|
||||
# ── Graph route ────────────────────────────────────────────────────────────────
|
||||
|
||||
@notes_bp.route("/graph", methods=["GET"])
|
||||
|
||||
@@ -59,3 +59,13 @@ async def clear_observations():
|
||||
uid = get_current_user_id()
|
||||
await clear_learned_data(uid)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@profile_bp.route("/observations", methods=["GET"])
|
||||
@login_required
|
||||
async def list_observations():
|
||||
uid = get_current_user_id()
|
||||
profile = await get_profile(uid)
|
||||
raw = list(profile.observations_raw or [])
|
||||
# Newest first, last 14 entries
|
||||
return jsonify({"observations": list(reversed(raw[-14:]))})
|
||||
|
||||
@@ -89,7 +89,11 @@ async def list_tasks_route():
|
||||
async def create_task_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
body = data.get("body", "") or data.get("description", "")
|
||||
# Description (user-stated goal) and body (machine-maintained summary) are
|
||||
# separate fields under the task-as-durable-record design. Don't fold one
|
||||
# into the other.
|
||||
body = data.get("body", "")
|
||||
description = data.get("description")
|
||||
tags = data.get("tags", [])
|
||||
|
||||
due_date = parse_iso_date(data.get("due_date"), "due_date")
|
||||
@@ -120,6 +124,7 @@ async def create_task_route():
|
||||
uid,
|
||||
title=data.get("title", ""),
|
||||
body=body,
|
||||
description=description,
|
||||
status=status,
|
||||
priority=priority,
|
||||
due_date=due_date,
|
||||
@@ -178,11 +183,12 @@ async def update_task_route(task_id: int):
|
||||
except ValueError:
|
||||
return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400
|
||||
|
||||
# Accept both "body" and "description" (prefer body)
|
||||
# Body and description are distinct fields under the task-as-durable-record
|
||||
# design. Don't alias one to the other.
|
||||
if "body" in data:
|
||||
fields["body"] = data["body"]
|
||||
elif "description" in data:
|
||||
fields["body"] = data["description"]
|
||||
if "description" in data:
|
||||
fields["description"] = data["description"]
|
||||
|
||||
if "due_date" in data:
|
||||
if data["due_date"]:
|
||||
@@ -276,3 +282,25 @@ async def delete_task_route(task_id: int):
|
||||
if not deleted:
|
||||
return not_found("Task")
|
||||
return "", 204
|
||||
|
||||
|
||||
@tasks_bp.route("/<int:task_id>/consolidate", methods=["POST"])
|
||||
@login_required
|
||||
async def consolidate_task_route(task_id: int):
|
||||
"""Manually trigger a consolidation pass for a task.
|
||||
|
||||
Bypasses the auto_consolidate_tasks setting (the user is asking
|
||||
explicitly). Returns the task's updated state including the freshly-
|
||||
written body and consolidated_at timestamp.
|
||||
"""
|
||||
uid = get_current_user_id()
|
||||
if not await can_write_note(uid, task_id):
|
||||
return jsonify({"error": "Permission denied"}), 403
|
||||
|
||||
from fabledassistant.services.consolidation import consolidate_task
|
||||
await consolidate_task(uid, task_id)
|
||||
|
||||
note = await get_note(uid, task_id)
|
||||
if note is None:
|
||||
return not_found("Task")
|
||||
return jsonify(note.to_dict())
|
||||
|
||||
@@ -150,6 +150,8 @@ async def export_full_backup() -> dict:
|
||||
"title": nv.title,
|
||||
"body": nv.body,
|
||||
"tags": nv.tags or [],
|
||||
"pin_kind": nv.pin_kind,
|
||||
"pin_label": nv.pin_label,
|
||||
"created_at": nv.created_at.isoformat(),
|
||||
}
|
||||
for nv in note_versions
|
||||
@@ -314,6 +316,8 @@ async def export_user_backup(user_id: int) -> dict:
|
||||
"title": nv.title,
|
||||
"body": nv.body,
|
||||
"tags": nv.tags or [],
|
||||
"pin_kind": nv.pin_kind,
|
||||
"pin_label": nv.pin_label,
|
||||
"created_at": nv.created_at.isoformat(),
|
||||
}
|
||||
for nv in note_versions
|
||||
@@ -605,6 +609,8 @@ async def _restore_v2(data: dict) -> dict:
|
||||
title=nv_data.get("title", ""),
|
||||
body=nv_data.get("body", ""),
|
||||
tags=nv_data.get("tags", []),
|
||||
pin_kind=nv_data.get("pin_kind"),
|
||||
pin_label=nv_data.get("pin_label"),
|
||||
created_at=_dt(nv_data.get("created_at")),
|
||||
)
|
||||
session.add(nv)
|
||||
|
||||
@@ -130,6 +130,17 @@ async def sync_user_events(user_id: int) -> dict:
|
||||
async with async_session() as session:
|
||||
for ev in remote_events:
|
||||
caldav_uid = ev["caldav_uid"]
|
||||
# Storage uses duration, not end_dt. Convert here so the
|
||||
# rest of this function can compare/upsert in one shape.
|
||||
ev_start = ev["start_dt"]
|
||||
ev_end = ev["end_dt"]
|
||||
ev_duration = (
|
||||
int((ev_end - ev_start).total_seconds() // 60)
|
||||
if ev_end is not None and ev_start is not None and ev_end > ev_start
|
||||
else None
|
||||
)
|
||||
ev["duration_minutes"] = ev_duration
|
||||
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
Event.user_id == user_id,
|
||||
@@ -145,8 +156,8 @@ async def sync_user_events(user_id: int) -> dict:
|
||||
uid=str(uuid.uuid4()),
|
||||
caldav_uid=caldav_uid,
|
||||
title=ev["title"],
|
||||
start_dt=ev["start_dt"],
|
||||
end_dt=ev["end_dt"],
|
||||
start_dt=ev_start,
|
||||
duration_minutes=ev_duration,
|
||||
all_day=ev["all_day"],
|
||||
description=ev["description"],
|
||||
location=ev["location"],
|
||||
@@ -157,7 +168,7 @@ async def sync_user_events(user_id: int) -> dict:
|
||||
else:
|
||||
# Update if anything changed
|
||||
changed = False
|
||||
for field in ("title", "start_dt", "end_dt", "all_day", "description", "location", "recurrence"):
|
||||
for field in ("title", "start_dt", "duration_minutes", "all_day", "description", "location", "recurrence"):
|
||||
if getattr(existing, field) != ev[field]:
|
||||
setattr(existing, field, ev[field])
|
||||
changed = True
|
||||
|
||||
@@ -207,6 +207,14 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
||||
d = dtend.dt
|
||||
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||
|
||||
# Storage uses duration, not end_dt. Convert iCal DTEND to a
|
||||
# minute count anchored on DTSTART. Treat invalid (end <= start)
|
||||
# incoming data as a point event rather than rejecting; we
|
||||
# don't control external CalDAV writers.
|
||||
duration_minutes = None
|
||||
if end_dt is not None and end_dt > start_dt:
|
||||
duration_minutes = int((end_dt - start_dt).total_seconds() // 60)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
|
||||
@@ -215,7 +223,7 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
||||
if existing:
|
||||
existing.title = title
|
||||
existing.start_dt = start_dt
|
||||
existing.end_dt = end_dt
|
||||
existing.duration_minutes = duration_minutes
|
||||
existing.all_day = all_day
|
||||
existing.description = description
|
||||
existing.location = location
|
||||
@@ -230,7 +238,7 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
||||
uid=ical_uid,
|
||||
title=title,
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
duration_minutes=duration_minutes,
|
||||
all_day=all_day,
|
||||
description=description,
|
||||
location=location,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Background task-body consolidation pipeline.
|
||||
|
||||
Reads a task's description (user goal) + work logs (chronological) and writes
|
||||
a 1-3 paragraph summary into Note.body via the background model. Triggered by
|
||||
log accumulation (debounced), status transitions to terminal states, and a
|
||||
manual API endpoint.
|
||||
|
||||
Design: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models.task_log import TaskLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Trigger thresholds. Tunable as constants; could be promoted to env vars if
|
||||
# the defaults prove wrong in practice.
|
||||
DEFAULT_LOG_THRESHOLD = 3
|
||||
MAX_LOGS_FOR_PROMPT = 50
|
||||
MAX_PROMPT_INPUT_CHARS = 8000
|
||||
|
||||
# Per-task asyncio locks to prevent two simultaneous consolidations of the
|
||||
# same task. Single-process; no cross-process coordination needed.
|
||||
_locks: dict[int, asyncio.Lock] = defaultdict(asyncio.Lock)
|
||||
|
||||
|
||||
async def _logs_since_last_consolidation(user_id: int, task_id: int) -> int:
|
||||
"""Count work logs that arrived after the most recent consolidation pass.
|
||||
|
||||
Returns 0 if the task doesn't exist. Returns the total log count when
|
||||
consolidated_at is NULL (i.e. never consolidated).
|
||||
"""
|
||||
async with async_session() as session:
|
||||
task = (
|
||||
await session.execute(
|
||||
select(Note).where(Note.id == task_id, Note.user_id == user_id)
|
||||
)
|
||||
).scalars().first()
|
||||
if task is None:
|
||||
return 0
|
||||
stmt = select(func.count(TaskLog.id)).where(
|
||||
TaskLog.task_id == task_id, TaskLog.user_id == user_id,
|
||||
)
|
||||
if task.consolidated_at is not None:
|
||||
stmt = stmt.where(TaskLog.created_at > task.consolidated_at)
|
||||
return int((await session.execute(stmt)).scalar() or 0)
|
||||
|
||||
|
||||
async def _auto_consolidate_enabled(user_id: int) -> bool:
|
||||
"""User-level setting; default true. Manual endpoint bypasses this."""
|
||||
from fabledassistant.services.settings import get_setting
|
||||
val = await get_setting(user_id, "auto_consolidate_tasks", "true")
|
||||
return str(val).lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
async def maybe_consolidate(user_id: int, task_id: int, *, reason: str) -> None:
|
||||
"""Debounced gate. Decides whether to schedule a consolidate_task pass.
|
||||
|
||||
reason='log_added' — gated by log count >= DEFAULT_LOG_THRESHOLD
|
||||
reason='task_closed' — proceeds unconditionally (subject to setting)
|
||||
"""
|
||||
if not await _auto_consolidate_enabled(user_id):
|
||||
return
|
||||
if reason == "log_added":
|
||||
n = await _logs_since_last_consolidation(user_id, task_id)
|
||||
if n < DEFAULT_LOG_THRESHOLD:
|
||||
return
|
||||
elif reason != "task_closed":
|
||||
logger.warning("maybe_consolidate: unknown reason %r", reason)
|
||||
return
|
||||
# Fire-and-forget; consolidate_task handles its own errors.
|
||||
asyncio.create_task(consolidate_task(user_id, task_id))
|
||||
|
||||
|
||||
def _build_consolidation_prompt(
|
||||
*, title: str, description: str | None, logs: list,
|
||||
) -> str:
|
||||
"""Build the LLM prompt for one consolidation pass.
|
||||
|
||||
Caps total log content at MAX_PROMPT_INPUT_CHARS; logs that don't fit
|
||||
are dropped. Caller is expected to slice to the most-recent window
|
||||
before calling.
|
||||
"""
|
||||
log_lines: list[str] = []
|
||||
chars = 0
|
||||
for log in logs:
|
||||
ts = log.created_at.isoformat() if getattr(log, "created_at", None) else "?"
|
||||
line = f"- [{ts}] {log.content}"
|
||||
if chars + len(line) > MAX_PROMPT_INPUT_CHARS:
|
||||
break
|
||||
log_lines.append(line)
|
||||
chars += len(line)
|
||||
|
||||
return (
|
||||
"You are summarizing the work done on a task. The user wrote the goal "
|
||||
"below; do not restate it. Read the chronological work-log entries and "
|
||||
"produce a 1-3 paragraph summary of: what was attempted, what worked, "
|
||||
"what failed, what's current state. Use the user's voice; cite specific "
|
||||
"commands/decisions; favor brevity over completeness. Output plain "
|
||||
"markdown body content only — no preamble.\n\n"
|
||||
f"TITLE: {title}\n"
|
||||
f"GOAL (read-only context): {description or '(no goal recorded)'}\n"
|
||||
f"WORK LOG (chronological):\n" + "\n".join(log_lines)
|
||||
)
|
||||
|
||||
|
||||
async def consolidate_task(user_id: int, task_id: int) -> None:
|
||||
"""Run a consolidation pass: read description + logs, write summary to body.
|
||||
|
||||
Errors are logged and swallowed so the fire-and-forget caller is never
|
||||
interrupted; on LLM failure the body and consolidated_at are left
|
||||
untouched and the next trigger retries.
|
||||
"""
|
||||
lock = _locks[task_id]
|
||||
if lock.locked():
|
||||
logger.debug(
|
||||
"consolidate_task: skipping — already running for task %d", task_id
|
||||
)
|
||||
return
|
||||
async with lock:
|
||||
try:
|
||||
async with async_session() as session:
|
||||
task = (
|
||||
await session.execute(
|
||||
select(Note).where(
|
||||
Note.id == task_id, Note.user_id == user_id,
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if task is None or not task.status:
|
||||
return # not a task, or missing
|
||||
logs = (
|
||||
await session.execute(
|
||||
select(TaskLog)
|
||||
.where(
|
||||
TaskLog.task_id == task_id,
|
||||
TaskLog.user_id == user_id,
|
||||
)
|
||||
.order_by(TaskLog.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
if not logs:
|
||||
return # nothing to summarize yet
|
||||
|
||||
title = task.title or ""
|
||||
description = task.description
|
||||
window = logs[-MAX_LOGS_FOR_PROMPT:]
|
||||
|
||||
prompt = _build_consolidation_prompt(
|
||||
title=title, description=description, logs=window,
|
||||
)
|
||||
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.config import Config
|
||||
|
||||
bg_model = await get_setting(
|
||||
user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL,
|
||||
)
|
||||
summary = await generate_completion(
|
||||
[{"role": "user", "content": prompt}],
|
||||
model=bg_model,
|
||||
max_tokens=800,
|
||||
num_ctx=4096,
|
||||
)
|
||||
if not summary or not summary.strip():
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as session:
|
||||
task = (
|
||||
await session.execute(
|
||||
select(Note).where(
|
||||
Note.id == task_id, Note.user_id == user_id,
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if task is None:
|
||||
return
|
||||
task.body = summary.strip()
|
||||
task.consolidated_at = now
|
||||
task.updated_at = now
|
||||
await session.commit()
|
||||
|
||||
from fabledassistant.services.embeddings import upsert_note_embedding
|
||||
await upsert_note_embedding(
|
||||
task_id, user_id, f"{title}\n{summary.strip()}".strip(),
|
||||
)
|
||||
logger.info(
|
||||
"consolidate_task: refreshed task %d body (%d chars)",
|
||||
task_id, len(summary),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"consolidate_task failed for task %d", task_id,
|
||||
)
|
||||
@@ -1,4 +1,13 @@
|
||||
"""Internal event store service with CalDAV push sync."""
|
||||
"""Internal event store service with CalDAV push sync.
|
||||
|
||||
Storage model: an event is anchored at ``start_dt`` and has an optional
|
||||
``duration_minutes``. The end of the event is *derived* via
|
||||
``Event.end_dt`` (a Python property), never stored. Callers may still
|
||||
pass ``end_dt`` on writes for ergonomic compatibility — the service
|
||||
converts to ``duration_minutes`` internally. This rules out the entire
|
||||
"end before start" bug class structurally (Fable #160 / migration
|
||||
0043). Open-ended events use ``duration_minutes = None``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -7,7 +16,7 @@ import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.event import Event
|
||||
@@ -15,11 +24,56 @@ from fabledassistant.models.event import Event
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_duration(
|
||||
*,
|
||||
start_dt: datetime,
|
||||
end_dt: datetime | None,
|
||||
duration_minutes: int | None,
|
||||
) -> int | None:
|
||||
"""Reduce (end_dt, duration_minutes) inputs to a single canonical
|
||||
``duration_minutes`` value.
|
||||
|
||||
Resolution order:
|
||||
1. If ``duration_minutes`` is explicit, use it (validate >= 0).
|
||||
If ``end_dt`` is also given, validate the two agree.
|
||||
2. Otherwise, derive from ``end_dt - start_dt``.
|
||||
3. Otherwise None (point event with no end).
|
||||
|
||||
Raises ``ValueError`` for any invalid combination — duration < 0,
|
||||
end_dt < start_dt, or end_dt and duration_minutes inconsistent.
|
||||
"""
|
||||
if duration_minutes is not None:
|
||||
if duration_minutes < 0:
|
||||
raise ValueError(
|
||||
f"duration_minutes must be >= 0, got {duration_minutes}"
|
||||
)
|
||||
if end_dt is not None:
|
||||
expected = int((end_dt - start_dt).total_seconds() // 60)
|
||||
if expected != duration_minutes:
|
||||
raise ValueError(
|
||||
f"end_dt ({end_dt.isoformat()}) implies "
|
||||
f"{expected} minutes but duration_minutes={duration_minutes} "
|
||||
f"was passed; pass only one or make them agree."
|
||||
)
|
||||
return duration_minutes
|
||||
if end_dt is not None:
|
||||
delta_seconds = (end_dt - start_dt).total_seconds()
|
||||
if delta_seconds < 0:
|
||||
raise ValueError(
|
||||
f"end_dt ({end_dt.isoformat()}) must be at or after "
|
||||
f"start_dt ({start_dt.isoformat()}); pass end_dt=None "
|
||||
f"or omit it for point events."
|
||||
)
|
||||
return int(delta_seconds // 60)
|
||||
return None
|
||||
|
||||
|
||||
async def create_event(
|
||||
user_id: int,
|
||||
title: str,
|
||||
start_dt: datetime,
|
||||
end_dt: datetime | None = None,
|
||||
duration_minutes: int | None = None,
|
||||
all_day: bool = False,
|
||||
description: str = "",
|
||||
location: str = "",
|
||||
@@ -27,12 +81,25 @@ async def create_event(
|
||||
recurrence: str | None = None,
|
||||
project_id: int | None = None,
|
||||
reminder_minutes: int | None = None,
|
||||
# CalDAV-only fields (not stored in DB, forwarded to push)
|
||||
# ``duration`` is a legacy alias kept for the calendar tool layer
|
||||
# and CalDAV pass-through callers; promotes to duration_minutes
|
||||
# when duration_minutes isn't otherwise specified.
|
||||
duration: int | None = None,
|
||||
attendees: list[str] | None = None,
|
||||
calendar_name: str | None = None,
|
||||
) -> Event:
|
||||
"""Create an event in the DB, then fire a CalDAV push task."""
|
||||
"""Create an event in the DB, then fire a CalDAV push task.
|
||||
|
||||
Either ``end_dt`` or ``duration_minutes`` may be supplied; the
|
||||
service converts to ``duration_minutes`` internally. Raises
|
||||
``ValueError`` on invalid combinations (negative duration, end
|
||||
before start, end/duration disagreement).
|
||||
"""
|
||||
if duration is not None and duration_minutes is None:
|
||||
duration_minutes = duration
|
||||
duration_minutes = _normalize_duration(
|
||||
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
|
||||
)
|
||||
uid = str(uuid.uuid4())
|
||||
async with async_session() as session:
|
||||
event = Event(
|
||||
@@ -40,7 +107,7 @@ async def create_event(
|
||||
uid=uid,
|
||||
title=title,
|
||||
start_dt=start_dt,
|
||||
end_dt=end_dt,
|
||||
duration_minutes=duration_minutes,
|
||||
all_day=all_day,
|
||||
description=description,
|
||||
location=location,
|
||||
@@ -54,7 +121,7 @@ async def create_event(
|
||||
await session.refresh(event)
|
||||
|
||||
extra_fields = {
|
||||
"duration": duration,
|
||||
"duration": duration_minutes,
|
||||
"reminder_minutes": reminder_minutes,
|
||||
"attendees": attendees,
|
||||
"calendar_name": calendar_name,
|
||||
@@ -80,66 +147,74 @@ async def list_events(
|
||||
"""List events for user_id that overlap [date_from, date_to].
|
||||
|
||||
Recurring events (with an RRULE recurrence string) are expanded into
|
||||
individual occurrences within the range. Non-recurring events are
|
||||
returned as-is. All results are sorted by start time and returned as
|
||||
dicts (same shape as Event.to_dict()).
|
||||
individual occurrences within the range. Non-recurring events are
|
||||
returned as-is. All results are sorted by start time and returned as
|
||||
dicts (same shape as ``Event.to_dict()``).
|
||||
|
||||
Filtering strategy: a coarse SQL prefilter (events that start on or
|
||||
before ``date_to``), then refine in Python using the event's derived
|
||||
end (``start_dt + duration_minutes``). Doing the end-of-event math
|
||||
in SQL would require Postgres-specific interval arithmetic; the
|
||||
Python-side refinement is a few row-loops over a small per-user
|
||||
result set, which is fine for personal-scale data and avoids
|
||||
coupling the query to a specific dialect.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
# Match strategy:
|
||||
# - Recurring events: fetch all, expand via rrule below.
|
||||
# - Non-recurring with an end_dt: standard overlap — starts before
|
||||
# date_to and ends after date_from.
|
||||
# - Non-recurring with no end_dt: treat as a point event at
|
||||
# start_dt, include only if start_dt falls within the window.
|
||||
# (Previously this branch matched any event with a null end_dt,
|
||||
# returning all past events as "happening today".)
|
||||
result = await session.execute(
|
||||
select(Event).where(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.user_id == user_id,
|
||||
or_(
|
||||
Event.recurrence.isnot(None),
|
||||
and_(
|
||||
Event.recurrence.is_(None),
|
||||
Event.start_dt <= date_to,
|
||||
or_(
|
||||
Event.end_dt >= date_from,
|
||||
and_(
|
||||
Event.end_dt.is_(None),
|
||||
Event.start_dt >= date_from,
|
||||
),
|
||||
),
|
||||
),
|
||||
Event.start_dt <= date_to,
|
||||
),
|
||||
).order_by(Event.start_dt)
|
||||
)
|
||||
.order_by(Event.start_dt)
|
||||
)
|
||||
events = list(result.scalars().all())
|
||||
|
||||
items: list[dict] = []
|
||||
for event in events:
|
||||
if not event.recurrence:
|
||||
items.append(event.to_dict())
|
||||
if event.recurrence:
|
||||
duration = (
|
||||
timedelta(minutes=event.duration_minutes)
|
||||
if event.duration_minutes is not None
|
||||
else None
|
||||
)
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occurrences = rule.between(date_from, date_to, inc=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
|
||||
# Fall back to canonical event row; still apply the
|
||||
# window check so a far-future canonical row doesn't
|
||||
# leak into today's list.
|
||||
if date_from <= event.start_dt <= date_to:
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
base = event.to_dict()
|
||||
for occ in occurrences:
|
||||
if occ.tzinfo is None:
|
||||
occ = occ.replace(tzinfo=timezone.utc)
|
||||
occurrence_dict = dict(base)
|
||||
occurrence_dict["start_dt"] = occ.isoformat()
|
||||
if duration is not None:
|
||||
occurrence_dict["end_dt"] = (occ + duration).isoformat()
|
||||
items.append(occurrence_dict)
|
||||
continue
|
||||
|
||||
# Expand recurring event occurrences within [date_from, date_to]
|
||||
duration = (event.end_dt - event.start_dt) if event.end_dt else None
|
||||
try:
|
||||
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
|
||||
occurrences = rule.between(date_from, date_to, inc=True)
|
||||
except Exception:
|
||||
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
|
||||
items.append(event.to_dict())
|
||||
continue
|
||||
|
||||
base = event.to_dict()
|
||||
for occ in occurrences:
|
||||
# Ensure occurrence is UTC-aware
|
||||
if occ.tzinfo is None:
|
||||
occ = occ.replace(tzinfo=timezone.utc)
|
||||
occurrence_dict = dict(base)
|
||||
occurrence_dict["start_dt"] = occ.isoformat()
|
||||
if duration is not None:
|
||||
occurrence_dict["end_dt"] = (occ + duration).isoformat()
|
||||
items.append(occurrence_dict)
|
||||
# Non-recurring: refine the coarse prefilter in Python using the
|
||||
# derived end_dt. A point event (duration None) is included when
|
||||
# its start is at or after date_from. A timed event is included
|
||||
# when its end is at or after date_from.
|
||||
derived_end = event.end_dt
|
||||
if derived_end is None:
|
||||
if event.start_dt >= date_from:
|
||||
items.append(event.to_dict())
|
||||
else:
|
||||
if derived_end >= date_from:
|
||||
items.append(event.to_dict())
|
||||
|
||||
items.sort(key=lambda x: x["start_dt"])
|
||||
return items
|
||||
@@ -173,7 +248,13 @@ async def search_events(
|
||||
|
||||
|
||||
async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
"""Partial update. Returns updated event or None if not found."""
|
||||
"""Partial update. Returns updated event or None if not found.
|
||||
|
||||
Accepts ``end_dt`` or ``duration_minutes`` (or both, validated for
|
||||
agreement). The service converts to ``duration_minutes`` before
|
||||
persisting; ``end_dt`` is never stored. Raises ``ValueError`` for
|
||||
invalid combinations against the post-update state.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Event).where(Event.id == event_id, Event.user_id == user_id)
|
||||
@@ -182,10 +263,39 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
if event is None:
|
||||
return None
|
||||
old_title = event.title # capture before mutation for CalDAV lookup
|
||||
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
|
||||
"location", "color", "recurrence", "project_id", "reminder_minutes"}
|
||||
# Nullable fields that callers can explicitly set to None to clear
|
||||
nullable = {"end_dt", "recurrence", "project_id", "reminder_minutes"}
|
||||
|
||||
# Resolve any end_dt/duration_minutes inputs against the
|
||||
# post-update start_dt. If neither is in the patch, leave the
|
||||
# existing duration_minutes alone.
|
||||
post_update_start = (
|
||||
fields["start_dt"]
|
||||
if fields.get("start_dt") is not None
|
||||
else event.start_dt
|
||||
)
|
||||
if "end_dt" in fields or "duration_minutes" in fields:
|
||||
new_end = fields.pop("end_dt", None)
|
||||
new_duration = fields.pop("duration_minutes", None)
|
||||
# If end_dt is in the patch but explicitly None, that's a
|
||||
# clear → duration_minutes = None. Same shape duration_minutes=None.
|
||||
if new_end is None and new_duration is None:
|
||||
fields["duration_minutes"] = None
|
||||
else:
|
||||
fields["duration_minutes"] = _normalize_duration(
|
||||
start_dt=post_update_start,
|
||||
end_dt=new_end,
|
||||
duration_minutes=new_duration,
|
||||
)
|
||||
|
||||
allowed = {
|
||||
"title", "start_dt", "duration_minutes", "all_day",
|
||||
"description", "location", "color", "recurrence",
|
||||
"project_id", "reminder_minutes",
|
||||
}
|
||||
# Nullable fields callers can explicitly clear by passing None
|
||||
nullable = {
|
||||
"duration_minutes", "recurrence", "project_id",
|
||||
"reminder_minutes",
|
||||
}
|
||||
for key, value in fields.items():
|
||||
if key in allowed and (value is not None or key in nullable):
|
||||
setattr(event, key, value)
|
||||
@@ -255,11 +365,12 @@ async def _push_create(event: Event, user_id: int, extra: dict) -> None:
|
||||
)
|
||||
if not await is_caldav_configured(user_id):
|
||||
return
|
||||
derived_end = event.end_dt # property: start + duration_minutes
|
||||
await caldav_create(
|
||||
user_id=user_id,
|
||||
title=event.title,
|
||||
start=event.start_dt.isoformat(),
|
||||
end=event.end_dt.isoformat() if event.end_dt else None,
|
||||
end=derived_end.isoformat() if derived_end else None,
|
||||
description=event.description or None,
|
||||
location=event.location or None,
|
||||
all_day=event.all_day,
|
||||
@@ -296,12 +407,13 @@ async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
|
||||
return
|
||||
# Use old_title so CalDAV can find the event even if the title was changed
|
||||
query_title = old_title or event.title
|
||||
derived_end = event.end_dt
|
||||
await caldav_update(
|
||||
user_id=user_id,
|
||||
query=query_title,
|
||||
title=event.title,
|
||||
start=event.start_dt.isoformat(),
|
||||
end=event.end_dt.isoformat() if event.end_dt else None,
|
||||
end=derived_end.isoformat() if derived_end else None,
|
||||
description=event.description or None,
|
||||
location=event.location or None,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Journal closeout — nightly extraction of profile observations.
|
||||
|
||||
Runs once per user per day at day_rollover_hour. Reads yesterday's /journal
|
||||
conversation, filters out assistant-authored auto-content (daily prep),
|
||||
asks the background LLM to extract user-side patterns/habits, and appends
|
||||
the bullets to user_profiles.observations_raw via append_observations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.services.user_profile import append_observations
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Message kinds whose content must NEVER be sent to the closeout LLM.
|
||||
# These are assistant-authored auto-blocks that would otherwise dominate
|
||||
# attention and leak back into "what the assistant has learned."
|
||||
EXCLUDED_KINDS: set[str] = {"daily_prep"}
|
||||
|
||||
|
||||
def _filter_messages(messages):
|
||||
"""Drop messages whose msg_metadata.kind is in EXCLUDED_KINDS.
|
||||
|
||||
Accepts any iterable of message-like objects with `role`, `content`,
|
||||
and `msg_metadata` attributes (real Message rows or SimpleNamespace).
|
||||
"""
|
||||
kept = []
|
||||
for m in messages:
|
||||
meta = getattr(m, "msg_metadata", None) or {}
|
||||
if meta.get("kind") in EXCLUDED_KINDS:
|
||||
continue
|
||||
kept.append(m)
|
||||
return kept
|
||||
|
||||
|
||||
_TRANSCRIPT_WINDOW = 20
|
||||
_CONTENT_CAP = 500
|
||||
|
||||
|
||||
def _build_transcript(messages) -> str:
|
||||
"""Format the last 20 messages as `ROLE: content[:500]` lines."""
|
||||
tail = list(messages)[-_TRANSCRIPT_WINDOW:]
|
||||
return "\n".join(
|
||||
f"{m.role.upper()}: {m.content[:_CONTENT_CAP]}" for m in tail
|
||||
)
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are reviewing a day's journal conversation to extract preference "
|
||||
"observations the USER revealed about themselves.\n\n"
|
||||
"Rules:\n"
|
||||
"- Only extract patterns, habits, recurring frustrations, or contextual "
|
||||
"facts the user said or demonstrated.\n"
|
||||
"- DO NOT restate facts that belong in structured fields: name, job title, "
|
||||
"industry, expertise level, response style, tone, interests. Those are "
|
||||
"handled separately.\n"
|
||||
"- DO NOT extract anything from the ASSISTANT turns about the user — only "
|
||||
"what the user themselves stated or demonstrated by their choices.\n"
|
||||
"- Write 2-5 short bullet points. Be specific and factual.\n"
|
||||
"- If nothing notable, output only: (nothing to note)"
|
||||
)
|
||||
|
||||
|
||||
async def run_for_user(user_id: int, yesterday: datetime.date) -> None:
|
||||
"""Extract preference observations from yesterday's journal conversation.
|
||||
|
||||
Skips silently when there is nothing meaningful to extract.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
conv_result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "journal",
|
||||
Conversation.day_date == yesterday,
|
||||
)
|
||||
)
|
||||
conv = conv_result.scalar_one_or_none()
|
||||
if conv is None:
|
||||
logger.debug("closeout: no journal conv for user %d on %s", user_id, yesterday)
|
||||
return
|
||||
|
||||
msg_result = await session.execute(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.conversation_id == conv.id,
|
||||
Message.role.in_(("user", "assistant")),
|
||||
or_(
|
||||
Message.msg_metadata.is_(None),
|
||||
~Message.msg_metadata["kind"].astext.in_(EXCLUDED_KINDS),
|
||||
),
|
||||
)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
messages = list(msg_result.scalars().all())
|
||||
|
||||
# Defensive second-pass filter (covers any message with metadata the
|
||||
# SQL JSON path can't reach, e.g. older rows where kind nesting differs).
|
||||
messages = _filter_messages(messages)
|
||||
|
||||
if len(messages) < 2:
|
||||
logger.debug("closeout: not enough messages for user %d (%d)", user_id, len(messages))
|
||||
return
|
||||
|
||||
transcript = _build_transcript(messages)
|
||||
model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
|
||||
|
||||
try:
|
||||
output = (await generate_completion(
|
||||
[
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": transcript},
|
||||
],
|
||||
model,
|
||||
)).strip()
|
||||
except Exception:
|
||||
logger.warning("closeout LLM failed for user %d", user_id, exc_info=True)
|
||||
return
|
||||
|
||||
if not output or "(nothing to note)" in output.lower():
|
||||
logger.debug("closeout: nothing to note for user %d", user_id)
|
||||
return
|
||||
|
||||
await append_observations(user_id, output)
|
||||
logger.info("closeout: appended observations for user %d (%s)", user_id, yesterday)
|
||||
@@ -18,10 +18,12 @@ from fabledassistant.services.user_profile import build_profile_context
|
||||
|
||||
JOURNAL_PERSONA = (
|
||||
"You are the user's assistant. They've opened their journal — a day-anchored "
|
||||
"conversation surface where they record their day. Behave like the rest of "
|
||||
"the app's chat: respond conversationally, ask follow-up questions about what "
|
||||
"they just said, verify details from earlier in the conversation when "
|
||||
"relevant, and use tools naturally to act on their behalf when it makes sense. "
|
||||
"conversation surface where they record their day. Your primary job is to "
|
||||
"CAPTURE what they share, not to advise on it. Treat journal mode as a quiet "
|
||||
"thinking-companion surface: record the beat, optionally ask one short "
|
||||
"follow-up that doesn't presume they want help, and let the user lead. "
|
||||
"Use tools to act on their behalf when they ask, not when you think they "
|
||||
"might find it useful. "
|
||||
"The day's prep message at the top of the conversation is your context — "
|
||||
"build on it, don't restate it."
|
||||
)
|
||||
@@ -57,6 +59,35 @@ these beats; if you skip the call, the beat is lost.
|
||||
Multiple distinct beats in one message → multiple record_moment calls,
|
||||
one per beat.
|
||||
|
||||
MOMENT PHRASING — write it the way the user would jot it themselves.
|
||||
First-person or imperative, never third-person observer voice.
|
||||
- GOOD: "Restaging Docker on the Bedford swarm; one Windows node had
|
||||
network breakage."
|
||||
- GOOD: "Appointment this Friday — details TBD."
|
||||
- GOOD: "Coffee with Sarah; she's hiring."
|
||||
- BAD: "The user mentioned having an appointment this Friday but
|
||||
hasn't provided details yet."
|
||||
- BAD: "User reports Docker swarm restage in progress."
|
||||
Strip "the user…" / "user mentioned…" / "user is…" framings entirely.
|
||||
|
||||
MOMENT ENTITY LINKING — be conservative.
|
||||
- Only attach a `task_titles` link when the user *explicitly references
|
||||
that task* in the message. Do NOT link to a task just because it's
|
||||
in the prep context or the only task currently open.
|
||||
- Only attach `place_names` you can ground in something the user
|
||||
actually said. Generic placeholders like "work" / "home" / "office"
|
||||
are NOT places — drop them and let the user name the real one if it
|
||||
matters.
|
||||
|
||||
EXISTING WORK — search before recording.
|
||||
- If the user describes ongoing or completed work that references a specific
|
||||
project or task by name or partial name (e.g. "the sebring task",
|
||||
"continuing on the AT&T circuit", "finished the auth refactor"), CALL
|
||||
search_notes FIRST to locate the existing task. Update its status or log
|
||||
work on it instead of recording a new moment when an obvious match exists.
|
||||
- Only call record_moment for that beat if no matching task surfaces and the
|
||||
user confirms they want a moment recorded.
|
||||
|
||||
WHEN LINKING ENTITIES: use the *_names parameters (person_names,
|
||||
place_names, task_titles, note_titles). Server resolves them to IDs by
|
||||
lookup. Do NOT pass *_ids unless you have an exact ID returned from
|
||||
@@ -89,6 +120,15 @@ RESPONSE STYLE:
|
||||
- Don't repeat a prior reply verbatim. If the user circles back on a theme,
|
||||
pick a specific concrete detail from the new message to react to.
|
||||
- Match the user's length. Short message → short reply. Don't pad.
|
||||
- DON'T offer troubleshooting steps, checklists, or generic process advice
|
||||
for the user's work unless they explicitly ask. When the user is logging
|
||||
what they're doing, they want to be heard, not coached. A statement like
|
||||
"I'm prepping for an ISP migration" should be acknowledged and recorded —
|
||||
not met with "Are you handling the network configuration yourself? Are
|
||||
there checks you need to do first?" If a follow-up would presume they
|
||||
want help, drop it.
|
||||
- No emojis. The journal is a thinking-companion surface; emojis read as
|
||||
chat-bot warmth that's out of register.
|
||||
"""
|
||||
|
||||
PHASE_GREETINGS = {
|
||||
@@ -134,7 +174,11 @@ async def build_journal_system_prompt(
|
||||
static_block = f"{JOURNAL_PERSONA}\n\n{JOURNAL_CALIBRATION}"
|
||||
|
||||
today_iso = day_date.isoformat()
|
||||
tz_block = f"Today is {today_iso} ({user_timezone})."
|
||||
# Include the day-of-week explicitly. LLMs are unreliable at deriving
|
||||
# weekday names from ISO dates, which causes "this Friday" / "next
|
||||
# Monday" to land on the wrong calendar day.
|
||||
weekday = day_date.strftime("%A")
|
||||
tz_block = f"Today is {weekday}, {today_iso} ({user_timezone})."
|
||||
|
||||
profile_context = await build_profile_context(user_id)
|
||||
profile_section = f"\n\n{profile_context}" if profile_context else ""
|
||||
|
||||
@@ -23,6 +23,7 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -38,6 +39,65 @@ from fabledassistant.services.weather import get_cached_weather_rows
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# How many days out from today an event needs to be before the prep treats
|
||||
# it as too far-future to surface. Catches recurring-event canonical rows
|
||||
# whose RRULE expansion missed (an `rrulestr` failure falls back to the
|
||||
# canonical event in `list_events`, which leaks far-future occurrences
|
||||
# into today's prep).
|
||||
_EVENT_PROXIMITY_DAYS = 7
|
||||
|
||||
|
||||
def _task_to_prep_dict(task, today: datetime.date) -> dict:
|
||||
"""Render a Note row as a prep-payload task entry, tagging overdue
|
||||
staleness when relevant so the prompt can frame it correctly."""
|
||||
d = {
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"status": task.status,
|
||||
"priority": task.priority,
|
||||
"due_date": task.due_date.isoformat() if task.due_date else None,
|
||||
}
|
||||
if task.due_date and task.due_date < today:
|
||||
d["days_overdue"] = (today - task.due_date).days
|
||||
return d
|
||||
|
||||
|
||||
def _filter_proximate_events(
|
||||
events: list[dict], *, day_date: datetime.date, user_tz: ZoneInfo
|
||||
) -> list[dict]:
|
||||
"""Drop events whose start_dt is more than ``_EVENT_PROXIMITY_DAYS``
|
||||
away from ``day_date`` in the user's local timezone.
|
||||
|
||||
Belt-and-suspenders against `list_events` returning a canonical
|
||||
far-future event (e.g. when RRULE expansion fails and the loop falls
|
||||
back to the original event row, regardless of date). The user
|
||||
observed "Birthday — 2026-09-29 (FREQ=YEARLY)" surfacing in every
|
||||
daily prep 5 months out; this filter keeps the prep proximate.
|
||||
"""
|
||||
proximate: list[dict] = []
|
||||
for e in events:
|
||||
raw = e.get("start_dt") or ""
|
||||
try:
|
||||
start_dt = datetime.datetime.fromisoformat(
|
||||
raw.replace("Z", "+00:00") if isinstance(raw, str) else ""
|
||||
)
|
||||
local_date = start_dt.astimezone(user_tz).date()
|
||||
delta = abs((local_date - day_date).days)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
# Unparseable date — keep the event rather than suppress real data.
|
||||
proximate.append(e)
|
||||
continue
|
||||
if delta <= _EVENT_PROXIMITY_DAYS:
|
||||
proximate.append(e)
|
||||
else:
|
||||
logger.info(
|
||||
"daily_prep: dropping non-proximate event %r start_local=%s "
|
||||
"(%d days from day %s)",
|
||||
e.get("title"), local_date.isoformat(), delta, day_date.isoformat(),
|
||||
)
|
||||
return proximate
|
||||
|
||||
|
||||
async def gather_daily_sections(
|
||||
*,
|
||||
user_id: int,
|
||||
@@ -48,47 +108,85 @@ async def gather_daily_sections(
|
||||
|
||||
Pure data fetching — no LLM call. Each section degrades to an empty
|
||||
list/dict on failure so the caller always gets a complete shape.
|
||||
|
||||
Tasks are returned in three explicit buckets so the prompt can frame
|
||||
overdue items correctly (instead of calling them "due today" — the
|
||||
pre-2026-04-29 behavior, before this rewrite).
|
||||
"""
|
||||
sections: dict = {}
|
||||
|
||||
next_day = day_date + datetime.timedelta(days=1)
|
||||
upcoming_end = day_date + datetime.timedelta(days=8)
|
||||
try:
|
||||
tasks_today, _ = await list_notes(
|
||||
user_id=user_id,
|
||||
is_task=True,
|
||||
status=["todo", "in_progress"],
|
||||
due_before=day_date,
|
||||
limit=20,
|
||||
sort="due_date",
|
||||
order="asc",
|
||||
due_today_rows, _ = await list_notes(
|
||||
user_id=user_id, is_task=True, status=["todo", "in_progress"],
|
||||
due_after=day_date, due_before=next_day,
|
||||
limit=20, sort="due_date", order="asc",
|
||||
)
|
||||
upcoming_rows, _ = await list_notes(
|
||||
user_id=user_id, is_task=True, status=["todo", "in_progress"],
|
||||
due_after=next_day, due_before=upcoming_end,
|
||||
limit=20, sort="due_date", order="asc",
|
||||
)
|
||||
overdue_rows, _ = await list_notes(
|
||||
user_id=user_id, is_task=True, status=["todo", "in_progress"],
|
||||
due_before=day_date,
|
||||
limit=20, sort="due_date", order="asc",
|
||||
)
|
||||
sections["tasks_due_today"] = [_task_to_prep_dict(t, day_date) for t in due_today_rows]
|
||||
sections["tasks_upcoming"] = [_task_to_prep_dict(t, day_date) for t in upcoming_rows]
|
||||
sections["tasks_overdue"] = [_task_to_prep_dict(t, day_date) for t in overdue_rows]
|
||||
# Backwards-compat alias for any consumers still reading sections["tasks"].
|
||||
# The combined view is more useful than the prior overdue-only behavior.
|
||||
sections["tasks"] = (
|
||||
sections["tasks_due_today"]
|
||||
+ sections["tasks_upcoming"]
|
||||
+ sections["tasks_overdue"]
|
||||
)
|
||||
sections["tasks"] = [
|
||||
{
|
||||
"id": t.id,
|
||||
"title": t.title,
|
||||
"status": t.status,
|
||||
"priority": t.priority,
|
||||
"due_date": t.due_date.isoformat() if t.due_date else None,
|
||||
}
|
||||
for t in tasks_today
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("daily_prep tasks section failed for user %d", user_id)
|
||||
sections["tasks_due_today"] = []
|
||||
sections["tasks_upcoming"] = []
|
||||
sections["tasks_overdue"] = []
|
||||
sections["tasks"] = []
|
||||
|
||||
try:
|
||||
day_start = datetime.datetime.combine(day_date, datetime.time.min)
|
||||
day_end = datetime.datetime.combine(day_date, datetime.time.max)
|
||||
sections["events"] = await list_events(
|
||||
try:
|
||||
user_tz = ZoneInfo(user_timezone)
|
||||
except Exception:
|
||||
logger.warning("daily_prep: invalid user_timezone %r — defaulting to UTC", user_timezone)
|
||||
user_tz = ZoneInfo("UTC")
|
||||
# Build the local-day window in the user's TZ, then convert to UTC
|
||||
# for the DB / RRULE expansion. A naive datetime here previously
|
||||
# caused rrule.between() to throw, falling back to the canonical
|
||||
# event row regardless of date — the source of stale recurring
|
||||
# events polluting every daily prep.
|
||||
day_start_local = datetime.datetime.combine(day_date, datetime.time.min, tzinfo=user_tz)
|
||||
day_end_local = datetime.datetime.combine(day_date, datetime.time.max, tzinfo=user_tz)
|
||||
day_start = day_start_local.astimezone(datetime.timezone.utc)
|
||||
day_end = day_end_local.astimezone(datetime.timezone.utc)
|
||||
all_events = await list_events(
|
||||
user_id=user_id,
|
||||
date_from=day_start,
|
||||
date_to=day_end,
|
||||
)
|
||||
sections["events"] = _filter_proximate_events(
|
||||
all_events, day_date=day_date, user_tz=user_tz,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("daily_prep events section failed for user %d", user_id)
|
||||
sections["events"] = []
|
||||
|
||||
try:
|
||||
weather_rows = await get_cached_weather_rows(user_id)
|
||||
# Lazy import: journal_scheduler imports this module for prep generation,
|
||||
# so a top-level import would cycle.
|
||||
from fabledassistant.services.journal_scheduler import get_journal_config
|
||||
cfg = await get_journal_config(user_id)
|
||||
valid_weather_keys = {
|
||||
key for key, loc in (cfg.get("locations") or {}).items()
|
||||
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
|
||||
}
|
||||
weather_rows = await get_cached_weather_rows(user_id, valid_weather_keys)
|
||||
sections["weather"] = [w.to_dict() for w in weather_rows]
|
||||
except Exception:
|
||||
logger.exception("daily_prep weather section failed for user %d", user_id)
|
||||
@@ -148,22 +246,40 @@ async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def _render_task_line(t: dict, *, include_due: bool, include_overdue: bool) -> str:
|
||||
line = f" - {t.get('title', '?')}"
|
||||
if include_overdue and t.get("days_overdue"):
|
||||
line += f" (due {t['due_date']}, {t['days_overdue']} days ago)"
|
||||
elif include_due and t.get("due_date"):
|
||||
line += f" (due {t['due_date']})"
|
||||
if t.get("priority") and t["priority"] not in (None, "none"):
|
||||
line += f" [{t['priority']} priority]"
|
||||
if t.get("status") == "in_progress":
|
||||
line += " [in progress]"
|
||||
return line
|
||||
|
||||
|
||||
def _render_sections_for_prompt(sections: dict) -> str:
|
||||
"""Render the gathered sections as a structured plain-text block for the LLM."""
|
||||
lines: list[str] = []
|
||||
|
||||
tasks = sections.get("tasks") or []
|
||||
if tasks:
|
||||
lines.append("TASKS (todo or in-progress):")
|
||||
for t in tasks[:12]:
|
||||
line = f" - {t.get('title', '?')}"
|
||||
if t.get("due_date"):
|
||||
line += f" (due {t['due_date']})"
|
||||
if t.get("priority") and t["priority"] not in (None, "none"):
|
||||
line += f" [{t['priority']} priority]"
|
||||
if t.get("status") == "in_progress":
|
||||
line += " [in progress]"
|
||||
lines.append(line)
|
||||
due_today = sections.get("tasks_due_today") or []
|
||||
upcoming = sections.get("tasks_upcoming") or []
|
||||
overdue = sections.get("tasks_overdue") or []
|
||||
if due_today:
|
||||
lines.append("TASKS DUE TODAY:")
|
||||
for t in due_today[:8]:
|
||||
lines.append(_render_task_line(t, include_due=False, include_overdue=False))
|
||||
lines.append("")
|
||||
if upcoming:
|
||||
lines.append("UPCOMING TASKS (next 7 days):")
|
||||
for t in upcoming[:8]:
|
||||
lines.append(_render_task_line(t, include_due=True, include_overdue=False))
|
||||
lines.append("")
|
||||
if overdue:
|
||||
lines.append("OVERDUE TASKS (still on the list, not currently due):")
|
||||
for t in overdue[:8]:
|
||||
lines.append(_render_task_line(t, include_due=False, include_overdue=True))
|
||||
lines.append("")
|
||||
|
||||
events = sections.get("events") or []
|
||||
@@ -242,6 +358,12 @@ _PREP_SYSTEM_PROMPT = (
|
||||
"keep the prose factual and useful, not sentimental.\n"
|
||||
"- 4 to 7 sentences total. Tight. No padding, no flowery openings, no \"Good morning\" "
|
||||
"greetings unless the actual content warrants two clauses' worth.\n"
|
||||
"- TASK BUCKETS — three sections may appear: TASKS DUE TODAY, UPCOMING TASKS, "
|
||||
"OVERDUE TASKS. Lead with TASKS DUE TODAY when present. Do NOT call overdue items "
|
||||
"\"due today\" — they aren't. When OVERDUE TASKS appears, surface it with the "
|
||||
"staleness duration (\"still on the list 68 days\") and frame it as something to "
|
||||
"revisit, not as today's work. If the only data is overdue, lead with it but "
|
||||
"frame it as a backlog reminder.\n"
|
||||
"- If RECENT JOURNAL MOMENTS or OPEN THREADS are present, mention one or two BRIEFLY "
|
||||
"at the end as context — not as the lead. Skip them if nothing notable.\n"
|
||||
"- Close with one short invitation to journal: \"What's on your mind?\", "
|
||||
|
||||
@@ -35,6 +35,7 @@ DEFAULT_CONFIG = {
|
||||
"day_rollover_hour": 4,
|
||||
"morning_end_hour": 12,
|
||||
"midday_end_hour": 18,
|
||||
"closeout_enabled": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -88,30 +89,96 @@ def _run_daily_prep_threadsafe(user_id: int) -> None:
|
||||
asyncio.run_coroutine_threadsafe(_do_daily_prep(user_id), _loop)
|
||||
|
||||
|
||||
async def _do_closeout(user_id: int) -> None:
|
||||
try:
|
||||
tz_str = await get_user_timezone(user_id)
|
||||
tz = _resolve_tz(tz_str)
|
||||
now = datetime.datetime.now(tz)
|
||||
# We just rolled into a new day in user-local time. The day that
|
||||
# just ended is yesterday's calendar date regardless of whether
|
||||
# rollover_hour is 0 or 4 — APScheduler fires precisely at the
|
||||
# configured hour so no clock-skew correction is needed.
|
||||
yesterday = now.date() - datetime.timedelta(days=1)
|
||||
from fabledassistant.services.journal_closeout import run_for_user
|
||||
await run_for_user(user_id=user_id, yesterday=yesterday)
|
||||
except Exception:
|
||||
logger.exception("Closeout failed for user %d", user_id)
|
||||
|
||||
|
||||
def _run_closeout_threadsafe(user_id: int) -> None:
|
||||
if _loop is None:
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(_do_closeout(user_id), _loop)
|
||||
|
||||
|
||||
async def _closeout_catchup(user_id: int) -> None:
|
||||
"""On startup, run yesterday's closeout once if the slot already passed
|
||||
and no entry for yesterday exists in observations_raw.
|
||||
"""
|
||||
try:
|
||||
tz_str = await get_user_timezone(user_id)
|
||||
tz = _resolve_tz(tz_str)
|
||||
config = await get_journal_config(user_id)
|
||||
if not config.get("closeout_enabled", True):
|
||||
return
|
||||
rollover_hour = int(config.get("day_rollover_hour", 4))
|
||||
now = datetime.datetime.now(tz)
|
||||
# Slot hasn't passed yet today → wait for the cron.
|
||||
if now.hour < rollover_hour:
|
||||
return
|
||||
yesterday = (now - datetime.timedelta(days=1)).date()
|
||||
|
||||
from fabledassistant.services.user_profile import get_profile
|
||||
profile = await get_profile(user_id)
|
||||
existing_dates = {
|
||||
(e or {}).get("date") for e in (profile.observations_raw or [])
|
||||
}
|
||||
if yesterday.isoformat() in existing_dates:
|
||||
return
|
||||
|
||||
from fabledassistant.services.journal_closeout import run_for_user
|
||||
await run_for_user(user_id=user_id, yesterday=yesterday)
|
||||
except Exception:
|
||||
logger.exception("Closeout catch-up failed for user %d", user_id)
|
||||
|
||||
|
||||
async def update_user_schedule(user_id: int) -> None:
|
||||
"""Add or replace this user's daily-prep job using their current config."""
|
||||
"""Add or replace this user's daily-prep + closeout jobs from current config."""
|
||||
if _scheduler is None:
|
||||
return
|
||||
job_id = f"journal_prep_{user_id}"
|
||||
if _scheduler.get_job(job_id):
|
||||
_scheduler.remove_job(job_id)
|
||||
|
||||
config = await get_journal_config(user_id)
|
||||
if not config.get("prep_enabled", True):
|
||||
return
|
||||
|
||||
tz_str = await get_user_timezone(user_id)
|
||||
tz = _resolve_tz(tz_str)
|
||||
prep_hour = int(config.get("prep_hour", 5))
|
||||
prep_minute = int(config.get("prep_minute", 0))
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_daily_prep_threadsafe,
|
||||
trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz),
|
||||
args=[user_id],
|
||||
id=job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
# ── Prep job ──────────────────────────────────────────────────────────
|
||||
prep_job_id = f"journal_prep_{user_id}"
|
||||
if _scheduler.get_job(prep_job_id):
|
||||
_scheduler.remove_job(prep_job_id)
|
||||
if config.get("prep_enabled", True):
|
||||
prep_hour = int(config.get("prep_hour", 5))
|
||||
prep_minute = int(config.get("prep_minute", 0))
|
||||
_scheduler.add_job(
|
||||
_run_daily_prep_threadsafe,
|
||||
trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz),
|
||||
args=[user_id],
|
||||
id=prep_job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# ── Closeout job ──────────────────────────────────────────────────────
|
||||
closeout_job_id = f"journal_closeout_{user_id}"
|
||||
if _scheduler.get_job(closeout_job_id):
|
||||
_scheduler.remove_job(closeout_job_id)
|
||||
if config.get("closeout_enabled", True):
|
||||
rollover_hour = int(config.get("day_rollover_hour", 4))
|
||||
_scheduler.add_job(
|
||||
_run_closeout_threadsafe,
|
||||
trigger=CronTrigger(hour=rollover_hour, minute=0, timezone=tz),
|
||||
args=[user_id],
|
||||
id=closeout_job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
|
||||
async def _register_all_user_jobs() -> None:
|
||||
@@ -119,6 +186,9 @@ async def _register_all_user_jobs() -> None:
|
||||
users = (await session.execute(select(User))).scalars().all()
|
||||
for user in users:
|
||||
await update_user_schedule(user.id)
|
||||
# Fire catch-up asynchronously so a slow LLM call doesn't block startup
|
||||
if _loop is not None:
|
||||
asyncio.run_coroutine_threadsafe(_closeout_catchup(user.id), _loop)
|
||||
|
||||
|
||||
def start_journal_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
|
||||
@@ -602,7 +602,12 @@ async def build_context(
|
||||
}
|
||||
|
||||
assistant_name = await get_setting(user_id, "assistant_name", "Fable")
|
||||
today = date_type.today().isoformat()
|
||||
_today_obj = date_type.today()
|
||||
today = _today_obj.isoformat()
|
||||
# Day-of-week paired with the ISO date so the model doesn't have to
|
||||
# derive the weekday — that derivation is a documented failure mode
|
||||
# for "this Friday" / "next Monday"-style requests.
|
||||
today_weekday = _today_obj.strftime("%A")
|
||||
has_caldav = await is_caldav_configured(user_id)
|
||||
|
||||
# Build tool usage guidance based on available integrations
|
||||
@@ -615,6 +620,7 @@ async def build_context(
|
||||
"CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.",
|
||||
"GROUNDING: When the user asks about their own data — tasks, notes, events, projects, news, anything stored in this system — call the relevant tool to see what actually exists before answering. Never assert facts about the user's data from memory, prior context, or assumption. If you are unsure whether something exists, check with a tool.",
|
||||
"HONESTY WHEN EMPTY: If a tool returns empty results (no matching tasks, no events in the date range, no search hits, no notes found), tell the user plainly that nothing matched. Do not fabricate example items, do not invent plausible-sounding meetings or deadlines to fill the response, and do not hedge with generic suggestions dressed up as real data. A direct 'you don't have anything on your calendar today' is always better than an invented event.",
|
||||
"EXISTING WORK: When the user describes ongoing or completed work that references a specific project or task by name or partial name, call search_notes first to locate the existing item. Only call record_moment, create_task, or create_note if no matching task surfaces and the user confirms.",
|
||||
]
|
||||
actions = [
|
||||
"create_note (also creates tasks — set status='todo')", "update_note", "delete_note",
|
||||
@@ -678,7 +684,7 @@ async def build_context(
|
||||
entities_context = await get_people_and_places_context(user_id)
|
||||
entities_section = f"\n\n{entities_context}" if entities_context else ""
|
||||
|
||||
dynamic_tail = f"\n\nToday's date is {today}.{tz_line}{profile_section}{entities_section}"
|
||||
dynamic_tail = f"\n\nToday is {today_weekday}, {today}.{tz_line}{profile_section}{entities_section}"
|
||||
|
||||
# --- System message: stable content only ---
|
||||
# Workspace context and history summary stay here because they carry
|
||||
|
||||
@@ -47,13 +47,18 @@ async def create_version(
|
||||
await session.commit()
|
||||
await session.refresh(version)
|
||||
|
||||
# Prune versions beyond MAX_VERSIONS
|
||||
# Prune rolling versions beyond MAX_VERSIONS. Pinned rows
|
||||
# (pin_kind IS NOT NULL) are excluded from both the counted
|
||||
# bucket and the deletion candidate set, so they survive
|
||||
# indefinitely regardless of rolling autosave volume.
|
||||
await session.execute(
|
||||
text("""
|
||||
DELETE FROM note_versions
|
||||
WHERE id IN (
|
||||
SELECT id FROM note_versions
|
||||
WHERE note_id = :note_id AND user_id = :user_id
|
||||
WHERE note_id = :note_id
|
||||
AND user_id = :user_id
|
||||
AND pin_kind IS NULL
|
||||
ORDER BY created_at DESC
|
||||
OFFSET :max_versions
|
||||
)
|
||||
|
||||
@@ -22,6 +22,17 @@ def _normalize_tags(tags: list[str]) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
# Type-nouns the LLM tends to include in search queries. Treating them as
|
||||
# required ILIKE terms drops literal-title matches; we strip them server-side
|
||||
# and let the `type` / `project` parameters scope results instead.
|
||||
_SEARCH_TYPE_NOUNS = {"task", "tasks", "note", "notes", "project", "projects"}
|
||||
|
||||
|
||||
def _strip_type_nouns(q: str) -> list[str]:
|
||||
"""Return q's tokens with type-nouns removed (case-insensitive)."""
|
||||
return [t for t in q.split() if t.lower() not in _SEARCH_TYPE_NOUNS]
|
||||
|
||||
|
||||
async def _maybe_reactivate_project(project_id: int) -> None:
|
||||
"""If a project is paused, reactivate it — activity indicates resumed work."""
|
||||
from fabledassistant.models.project import Project
|
||||
@@ -65,6 +76,7 @@ async def create_note(
|
||||
user_id: int,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
description: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
parent_id: int | None = None,
|
||||
project_id: int | None = None,
|
||||
@@ -92,6 +104,7 @@ async def create_note(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
body=body,
|
||||
description=description,
|
||||
tags=_normalize_tags(tags or []),
|
||||
parent_id=parent_id,
|
||||
project_id=project_id,
|
||||
@@ -155,7 +168,7 @@ async def list_notes(
|
||||
count_query = count_query.where(Note.status.is_(None))
|
||||
|
||||
if q:
|
||||
terms = q.split()
|
||||
terms = _strip_type_nouns(q)
|
||||
for term in terms:
|
||||
escaped_term = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
pattern = f"%{escaped_term}%"
|
||||
@@ -268,6 +281,8 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
|
||||
old_body = note.body
|
||||
old_title = note.title
|
||||
old_tags = list(note.tags or [])
|
||||
# Snapshot status to detect terminal transitions for consolidation trigger.
|
||||
old_status = note.status
|
||||
for key, value in fields.items():
|
||||
if not hasattr(note, key):
|
||||
continue
|
||||
@@ -312,6 +327,13 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
|
||||
from fabledassistant.services.note_versions import create_version
|
||||
await create_version(user_id, note_id, old_body, old_title, old_tags)
|
||||
|
||||
# Trigger consolidation when a task transitions into a terminal status.
|
||||
# Captured before mutation; the gate inside maybe_consolidate handles the
|
||||
# auto-consolidate setting.
|
||||
if note.status in ("done", "cancelled") and old_status != note.status:
|
||||
from fabledassistant.services.consolidation import maybe_consolidate
|
||||
await maybe_consolidate(user_id, note.id, reason="task_closed")
|
||||
|
||||
if note.project_id is not None:
|
||||
await _maybe_reactivate_project(note.project_id)
|
||||
await _maybe_trigger_project_summary(user_id, note.project_id)
|
||||
|
||||
@@ -199,7 +199,10 @@ async def get_project_summary(user_id: int, project_id: int) -> dict:
|
||||
)
|
||||
.group_by(Note.status)
|
||||
)
|
||||
task_counts: dict[str, int] = {}
|
||||
# Initialise all three lifecycle keys to 0 so consumers can sum them
|
||||
# safely without `?? 0` guards. Frontend interface declares all three
|
||||
# as required; rendering `undefined + N` yields NaN.
|
||||
task_counts: dict[str, int] = {"todo": 0, "in_progress": 0, "done": 0}
|
||||
for status, count in task_rows.fetchall():
|
||||
task_counts[status] = count
|
||||
|
||||
|
||||
@@ -22,31 +22,71 @@ def schedule_embedding(note_id: int, user_id: int, title: str, body: str) -> Non
|
||||
asyncio.create_task(upsert_note_embedding(note_id, user_id, text))
|
||||
|
||||
|
||||
_PROJECT_QUERY_NOISE = {"project", "projects"}
|
||||
|
||||
|
||||
def _normalize(s: str) -> str:
|
||||
"""Lowercase and collapse non-alphanumerics to single spaces."""
|
||||
return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip()
|
||||
|
||||
|
||||
def _normalize_query(query: str) -> str:
|
||||
"""Normalize plus drop trailing type-nouns ('project' / 'projects')
|
||||
that users add as filler when referring to a project by name."""
|
||||
tokens = [t for t in _normalize(query).split() if t not in _PROJECT_QUERY_NOISE]
|
||||
return " ".join(tokens)
|
||||
|
||||
|
||||
def score_project_match(query: str, project) -> float:
|
||||
"""Score how well `query` matches `project`. Range [0.0, 1.0].
|
||||
|
||||
Tiered: exact title → 1.0, substring either-way → 0.85, query found in
|
||||
description/summary → 0.70, otherwise SequenceMatcher ratio against the
|
||||
title. Substring tiers exist because LLM-generated colloquial queries
|
||||
(e.g. "famous supply project" for "Famous-Supply Work topics") would
|
||||
otherwise score too low under pure SequenceMatcher and be treated as
|
||||
no match. Filler words like "project" are stripped from the query so
|
||||
the substring check still fires.
|
||||
"""
|
||||
q = _normalize_query(query)
|
||||
if not q:
|
||||
return 0.0
|
||||
title = _normalize(project.title)
|
||||
description = _normalize(project.description or "")
|
||||
summary = _normalize(project.auto_summary or "")
|
||||
combined = f"{title} {description} {summary}".strip()
|
||||
|
||||
if q == title:
|
||||
return 1.0
|
||||
if q in title or title in q:
|
||||
return 0.85
|
||||
if q in combined:
|
||||
return 0.70
|
||||
# SequenceMatcher against the title — comparing against `combined`
|
||||
# dilutes the ratio with long description/summary text and produces
|
||||
# uniformly low scores even for plausible matches.
|
||||
return SequenceMatcher(None, q, title).ratio()
|
||||
|
||||
|
||||
async def resolve_project(user_id: int, project_name: str):
|
||||
"""Exact-then-fuzzy project lookup. Returns the Project or None.
|
||||
"""Exact-then-scored project lookup. Returns the Project or None.
|
||||
|
||||
Resolution order:
|
||||
1. Exact title match (case-insensitive via DB)
|
||||
2. project_name is a substring of an existing title
|
||||
3. Existing title is a substring of project_name
|
||||
4. SequenceMatcher ratio >= 0.55
|
||||
1. Exact title match (case-insensitive via DB query).
|
||||
2. Highest `score_project_match` across all projects, threshold 0.55.
|
||||
"""
|
||||
from fabledassistant.services.projects import get_project_by_title, list_projects
|
||||
|
||||
proj = await get_project_by_title(user_id, project_name)
|
||||
if proj is not None:
|
||||
return proj
|
||||
needle = project_name.lower().strip()
|
||||
|
||||
all_p = await list_projects(user_id)
|
||||
best, best_score = None, 0.0
|
||||
for p in all_p:
|
||||
haystack = p.title.lower().strip()
|
||||
if needle in haystack or haystack in needle:
|
||||
return p
|
||||
best, best_r = None, 0.0
|
||||
for p in all_p:
|
||||
r = SequenceMatcher(None, needle, p.title.lower().strip()).ratio()
|
||||
if r >= 0.55 and r > best_r:
|
||||
best, best_r = p, r
|
||||
score = score_project_match(project_name, p)
|
||||
if score >= 0.55 and score > best_score:
|
||||
best, best_score = p, score
|
||||
return best
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from datetime import date as _date, datetime, time as _time, timezone
|
||||
|
||||
from fabledassistant.services.events import (
|
||||
create_event as events_create_event,
|
||||
@@ -17,10 +18,49 @@ from fabledassistant.services.tools._registry import tool
|
||||
from fabledassistant.services.tz import get_user_tz
|
||||
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
_TIME_RE = re.compile(r"^\d{2}:\d{2}(:\d{2})?$")
|
||||
|
||||
|
||||
async def _combine_local_in_user_tz(
|
||||
user_id: int, date_str: str, time_str: str | None
|
||||
) -> datetime:
|
||||
"""Build a UTC datetime from separate date and time strings.
|
||||
|
||||
The whole point of this helper: a `YYYY-MM-DD` string carries no TZ
|
||||
metadata that a model could mis-tag, so the calendar day cannot drift
|
||||
across the local→UTC boundary. The wall-clock `HH:MM` likewise has no
|
||||
TZ; we attach the user's local zone explicitly via ``datetime.combine``
|
||||
and then convert to UTC for storage.
|
||||
|
||||
Strict shape validation rejects anything that isn't a bare date or a
|
||||
bare time — no `2026-05-01Z`, no `08:00 UTC` slipping through.
|
||||
"""
|
||||
if not _DATE_RE.match(date_str):
|
||||
raise ValueError(
|
||||
f"start_date / end_date must be YYYY-MM-DD with no timezone; got {date_str!r}"
|
||||
)
|
||||
d = _date.fromisoformat(date_str)
|
||||
if time_str is None or time_str == "":
|
||||
t = _time(0, 0)
|
||||
else:
|
||||
if not _TIME_RE.match(time_str):
|
||||
raise ValueError(
|
||||
f"start_time / end_time must be HH:MM (or HH:MM:SS), no timezone; got {time_str!r}"
|
||||
)
|
||||
t = _time.fromisoformat(time_str)
|
||||
user_tz = await get_user_tz(user_id)
|
||||
local = datetime.combine(d, t, tzinfo=user_tz)
|
||||
return local.astimezone(timezone.utc)
|
||||
|
||||
|
||||
async def _parse_datetime_in_user_tz(
|
||||
user_id: int, value: str
|
||||
) -> tuple[datetime, bool]:
|
||||
"""Parse a date/datetime string from the model into a UTC-aware datetime.
|
||||
"""Legacy single-string parser. Kept as a fallback when the model
|
||||
emits the older `start` / `end` shape; new calls should use
|
||||
`start_date`+`start_time` (and `end_date`+`end_time`) which sidestep
|
||||
the TZ-tagging foot-gun this parser is vulnerable to.
|
||||
|
||||
Naive inputs are interpreted in the **user's local timezone** and then
|
||||
converted to UTC for storage. Never default to UTC for naive inputs —
|
||||
@@ -38,14 +78,178 @@ async def _parse_datetime_in_user_tz(
|
||||
return dt.astimezone(timezone.utc), was_date_only
|
||||
|
||||
|
||||
async def _resolve_event_start(
|
||||
user_id: int, args: dict
|
||||
) -> tuple[datetime, bool]:
|
||||
"""Resolve the start datetime from either the new split fields
|
||||
(`start_date` + optional `start_time`) or the legacy combined `start`.
|
||||
Returns ``(utc_datetime, was_date_only)``."""
|
||||
if "start_date" in args and args["start_date"]:
|
||||
date_str = args["start_date"]
|
||||
time_str = args.get("start_time") or None
|
||||
return await _combine_local_in_user_tz(user_id, date_str, time_str), time_str is None
|
||||
if "start" in args and args["start"]:
|
||||
return await _parse_datetime_in_user_tz(user_id, args["start"])
|
||||
raise ValueError("Either start_date or start is required")
|
||||
|
||||
|
||||
async def _resolve_event_end(
|
||||
user_id: int, args: dict
|
||||
) -> datetime | None:
|
||||
"""Resolve the end datetime from either the new split fields or the
|
||||
legacy combined `end`. Returns ``None`` when no end fields are set."""
|
||||
if "end_date" in args and args["end_date"]:
|
||||
date_str = args["end_date"]
|
||||
time_str = args.get("end_time") or None
|
||||
return await _combine_local_in_user_tz(user_id, date_str, time_str)
|
||||
if "end" in args and args["end"]:
|
||||
dt, _ = await _parse_datetime_in_user_tz(user_id, args["end"])
|
||||
return dt
|
||||
return None
|
||||
|
||||
|
||||
def _candidate_summary(event) -> dict:
|
||||
"""Compact event summary used in ambiguous-match responses.
|
||||
|
||||
Keeps the candidate list small so the model can disambiguate from
|
||||
the same turn without bloating context. Includes id (for the
|
||||
follow-up call), title, start_dt, and location when present.
|
||||
"""
|
||||
return {
|
||||
"id": event.id,
|
||||
"title": event.title,
|
||||
"start_dt": event.start_dt.isoformat() if event.start_dt else None,
|
||||
"location": event.location or None,
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_event_for_action(
|
||||
*, user_id: int, arguments: dict, action: str,
|
||||
):
|
||||
"""Pick the single event the model intends to update or delete.
|
||||
|
||||
Resolution rules:
|
||||
- ``event_id`` in arguments → exact lookup (skip query). Used by
|
||||
the model to disambiguate after a multi-match refusal.
|
||||
- else ``query`` → ``find_events_by_query``:
|
||||
- 0 results → return error tuple ("not_found", ...)
|
||||
- 1 result → return that event
|
||||
- 2+ results → return ("ambiguous", error, candidates) so the
|
||||
caller can refuse the call and show candidates to the model.
|
||||
|
||||
Returns either an Event (success) or a 2- or 3-tuple of
|
||||
``(error_kind, error_dict)`` for the caller to translate into a
|
||||
tool-call response.
|
||||
"""
|
||||
from fabledassistant.services.events import get_event
|
||||
|
||||
event_id = arguments.get("event_id")
|
||||
if event_id is not None:
|
||||
try:
|
||||
event_id_int = int(event_id)
|
||||
except (TypeError, ValueError):
|
||||
return ("invalid_id", {
|
||||
"success": False,
|
||||
"error": f"event_id must be an integer; got {event_id!r}.",
|
||||
})
|
||||
ev = await get_event(user_id=user_id, event_id=event_id_int)
|
||||
if ev is None:
|
||||
return ("not_found", {
|
||||
"success": False,
|
||||
"error": f"No event found with id={event_id_int}.",
|
||||
})
|
||||
return ev
|
||||
|
||||
query = arguments.get("query", "")
|
||||
if not query:
|
||||
return ("invalid_query", {
|
||||
"success": False,
|
||||
"error": "Either query or event_id is required.",
|
||||
})
|
||||
matches = await find_events_by_query(user_id=user_id, query=query)
|
||||
if not matches:
|
||||
return ("not_found", {
|
||||
"success": False,
|
||||
"error": f"No event found matching {query!r}.",
|
||||
})
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
# Multi-match: refuse and surface candidates so the model can
|
||||
# disambiguate via event_id on the next call. Prevents the silent-
|
||||
# picks-matches[0] failure mode that mutated the wrong event in the
|
||||
# 2026-04-29 dentist-appointment incident (Fable #161).
|
||||
return ("ambiguous", {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Found {len(matches)} events matching {query!r}. "
|
||||
f"Pick one by passing `event_id` instead of `query`, "
|
||||
f"or refine the search term to match a single event."
|
||||
),
|
||||
"action": action,
|
||||
"candidates": [_candidate_summary(m) for m in matches[:8]],
|
||||
})
|
||||
|
||||
|
||||
def _validate_weekday(start_dt_utc: datetime, user_tz, expected: str | None) -> str | None:
|
||||
"""Verify the resolved local date falls on the expected day of the week.
|
||||
|
||||
Models routinely miscompute "this Friday" / "next Monday" when the
|
||||
system prompt only carries an ISO date without a weekday. When the
|
||||
model passes `expected_weekday`, the backend rejects mismatches with
|
||||
a self-correcting error message naming the actual weekday.
|
||||
|
||||
Returns an error string on mismatch, or ``None`` when the check
|
||||
passes (or no expected weekday was supplied).
|
||||
"""
|
||||
if not expected:
|
||||
return None
|
||||
expected_norm = expected.strip().lower()
|
||||
valid = {"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
|
||||
if expected_norm not in valid:
|
||||
return f"expected_weekday must be a full English weekday name; got {expected!r}."
|
||||
local = start_dt_utc.astimezone(user_tz)
|
||||
actual = local.strftime("%A").lower()
|
||||
if actual == expected_norm:
|
||||
return None
|
||||
return (
|
||||
f"Date {local.date().isoformat()} falls on {actual.title()}, "
|
||||
f"not {expected_norm.title()}. Recompute the date for "
|
||||
f"{expected_norm.title()} or confirm with the user before retrying."
|
||||
)
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_event",
|
||||
description="Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event. Pass dates and datetimes in the user's local time — a bare date like '2026-09-30' is interpreted as that day in the user's configured timezone.",
|
||||
description=(
|
||||
"Create a calendar event for the user. Use this when the user asks "
|
||||
"to schedule, add, or create a meeting, appointment, or event. "
|
||||
"Always pass `start_date` (YYYY-MM-DD) and `start_time` (HH:MM) as "
|
||||
"separate fields in the user's local time — never combine them and "
|
||||
"never include a timezone suffix. The server attaches the user's "
|
||||
"configured timezone. Omit `start_time` (or set `all_day=true`) "
|
||||
"for all-day events like birthdays or holidays. "
|
||||
"When the user names a weekday ('this Friday', 'next Monday'), "
|
||||
"state the resolved calendar date in your reply BEFORE calling "
|
||||
"this tool, and pass `expected_weekday` so the server can verify "
|
||||
"the date falls on the day you intended.\n\n"
|
||||
"DON'T call this tool with placeholder values. If the user "
|
||||
"mentions an event without giving you concrete details (a real "
|
||||
"title, a specific time, and location when it applies), record "
|
||||
"a moment instead and ask for the missing pieces — do NOT create "
|
||||
"an event with a stand-in title like 'Appointment', 'Meeting', "
|
||||
"or 'Event' and a description that says 'details TBD'. Wait for "
|
||||
"the user's reply, then call create_event ONCE with the actual "
|
||||
"title, time, and location. Premature placeholder events pollute "
|
||||
"the calendar and require an immediate update_event to fix — "
|
||||
"both visible to the user, neither what they asked for."
|
||||
),
|
||||
parameters={
|
||||
"title": {"type": "string", "description": "A descriptive event title"},
|
||||
"start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (YYYY-MM-DDTHH:MM) in the user's local time. Include a timezone offset only if the user explicitly mentions one."},
|
||||
"end": {"type": "string", "description": "Optional end date or datetime in the user's local time"},
|
||||
"duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end is set or all_day is true)"},
|
||||
"start_date": {"type": "string", "description": "Start calendar date as YYYY-MM-DD in the user's local time. No timezone suffix."},
|
||||
"start_time": {"type": "string", "description": "Start wall-clock time as HH:MM (24-hour). Omit for all-day events. No timezone suffix."},
|
||||
"end_date": {"type": "string", "description": "Optional end calendar date as YYYY-MM-DD."},
|
||||
"end_time": {"type": "string", "description": "Optional end wall-clock time as HH:MM."},
|
||||
"duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end_date/end_time is set or all_day is true)"},
|
||||
"description": {"type": "string", "description": "Optional event description"},
|
||||
"location": {"type": "string", "description": "Optional event location"},
|
||||
"color": {"type": "string", "description": "Optional hex color for the event (e.g. '#6366f1')"},
|
||||
@@ -55,30 +259,36 @@ async def _parse_datetime_in_user_tz(
|
||||
"attendees": {"type": "array", "items": {"type": "string"}, "description": "Optional list of attendee email addresses"},
|
||||
"calendar_name": {"type": "string", "description": "Optional calendar name to create the event in. Falls back to default calendar."},
|
||||
"project": {"type": "string", "description": "Optional project name to associate this event with"},
|
||||
"expected_weekday": {"type": "string", "description": "Optional weekday name (e.g. 'friday') the start_date should fall on. Pass this whenever the user names a weekday so the server can verify the date is correct. Rejects with a corrective error if the date falls on a different day."},
|
||||
# Legacy combined fields kept for backward compatibility with saved
|
||||
# tool-call payloads in conversation history. New calls should use
|
||||
# start_date + start_time. Hidden from typical model output via the
|
||||
# description above; still accepted by the resolver as a fallback.
|
||||
"start": {"type": "string", "description": "[Deprecated] Combined start datetime — prefer start_date + start_time."},
|
||||
"end": {"type": "string", "description": "[Deprecated] Combined end datetime — prefer end_date + end_time."},
|
||||
},
|
||||
required=["title", "start"],
|
||||
required=["title"],
|
||||
)
|
||||
async def create_event_tool(*, user_id, arguments, **_ctx):
|
||||
start_str = arguments["start"]
|
||||
end_str = arguments.get("end")
|
||||
all_day = arguments.get("all_day", False)
|
||||
try:
|
||||
# Naive dates/datetimes are interpreted in the user's local
|
||||
# timezone, not UTC. Storing UTC for naive inputs caused all-day
|
||||
# events to land on the previous day for negative-offset users.
|
||||
start_dt, start_was_date_only = await _parse_datetime_in_user_tz(
|
||||
user_id, start_str
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid start datetime: {start_str!r}"}
|
||||
start_dt, start_was_date_only = await _resolve_event_start(user_id, arguments)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
except TypeError as exc:
|
||||
return {"success": False, "error": f"Invalid start: {exc}"}
|
||||
if start_was_date_only:
|
||||
all_day = True
|
||||
end_dt = None
|
||||
if end_str:
|
||||
try:
|
||||
end_dt, _ = await _parse_datetime_in_user_tz(user_id, end_str)
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid end datetime: {end_str!r}"}
|
||||
user_tz = await get_user_tz(user_id)
|
||||
weekday_err = _validate_weekday(start_dt, user_tz, arguments.get("expected_weekday"))
|
||||
if weekday_err:
|
||||
return {"success": False, "error": weekday_err}
|
||||
try:
|
||||
end_dt = await _resolve_event_end(user_id, arguments)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
except TypeError as exc:
|
||||
return {"success": False, "error": f"Invalid end: {exc}"}
|
||||
project_id = None
|
||||
project_name = arguments.get("project")
|
||||
if project_name:
|
||||
@@ -168,27 +378,51 @@ async def search_events_tool(*, user_id, arguments, **_ctx):
|
||||
|
||||
@tool(
|
||||
name="update_event",
|
||||
description="Update an existing calendar event. Use this when the user asks to change, move, reschedule, or modify an event.",
|
||||
description=(
|
||||
"Update an existing calendar event. Use this when the user asks to "
|
||||
"change, move, reschedule, or modify an event. Pass `start_date` "
|
||||
"(YYYY-MM-DD) and `start_time` (HH:MM) as separate fields in the "
|
||||
"user's local time when rescheduling — never combine them, never "
|
||||
"include a timezone suffix. "
|
||||
"When the user names a weekday ('move to Friday'), state the "
|
||||
"resolved calendar date in your reply BEFORE calling this tool, "
|
||||
"and pass `expected_weekday` so the server can verify the date "
|
||||
"falls on the day you intended.\n\n"
|
||||
"Identify the event with EITHER `query` (a title substring) OR "
|
||||
"`event_id` (when you already have an exact id from a prior tool "
|
||||
"result). If `query` matches multiple events, the tool returns "
|
||||
"an ambiguity error with a candidate list — pick one by passing "
|
||||
"its `event_id` on the next call, or refine the query so it "
|
||||
"matches a single event."
|
||||
),
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "Search term to find the event to update (matches against title)"},
|
||||
"query": {"type": "string", "description": "Search term to find the event to update (matches against title). Required unless event_id is set."},
|
||||
"event_id": {"type": "integer", "description": "Exact event id, used to disambiguate when a prior call returned multiple candidates. Takes precedence over query."},
|
||||
"title": {"type": "string", "description": "New title for the event"},
|
||||
"start": {"type": "string", "description": "New start datetime in ISO 8601 format"},
|
||||
"end": {"type": "string", "description": "New end datetime in ISO 8601 format"},
|
||||
"start_date": {"type": "string", "description": "New start calendar date as YYYY-MM-DD in the user's local time. No timezone suffix."},
|
||||
"start_time": {"type": "string", "description": "New start wall-clock time as HH:MM. No timezone suffix."},
|
||||
"end_date": {"type": "string", "description": "New end calendar date as YYYY-MM-DD."},
|
||||
"end_time": {"type": "string", "description": "New end wall-clock time as HH:MM."},
|
||||
"all_day": {"type": "boolean", "description": "Whether the event is all-day"},
|
||||
"description": {"type": "string", "description": "New event description"},
|
||||
"location": {"type": "string", "description": "New event location"},
|
||||
"color": {"type": "string", "description": "New hex color for the event (e.g. '#6366f1')"},
|
||||
"recurrence": {"type": "string", "description": "New iCalendar RRULE"},
|
||||
"reminder_minutes": {"type": "integer", "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder."},
|
||||
"expected_weekday": {"type": "string", "description": "Optional weekday name (e.g. 'friday') the new start_date should fall on. Pass whenever the user names a weekday."},
|
||||
# Legacy combined fields kept for backcompat — see create_event.
|
||||
"start": {"type": "string", "description": "[Deprecated] Combined start datetime — prefer start_date + start_time."},
|
||||
"end": {"type": "string", "description": "[Deprecated] Combined end datetime — prefer end_date + end_time."},
|
||||
},
|
||||
required=["query"],
|
||||
required=[],
|
||||
)
|
||||
async def update_event_tool(*, user_id, arguments, **_ctx):
|
||||
query = arguments.get("query", "")
|
||||
matches = await find_events_by_query(user_id=user_id, query=query)
|
||||
if not matches:
|
||||
return {"success": False, "error": f"No event found matching '{query}'."}
|
||||
event_to_update = matches[0]
|
||||
resolved = await _resolve_event_for_action(
|
||||
user_id=user_id, arguments=arguments, action="update",
|
||||
)
|
||||
if isinstance(resolved, tuple):
|
||||
return resolved[1] # error dict from the resolver
|
||||
event_to_update = resolved
|
||||
fields: dict = {}
|
||||
for str_field in ("title", "description", "location", "color", "recurrence"):
|
||||
if arguments.get(str_field) is not None:
|
||||
@@ -198,16 +432,24 @@ async def update_event_tool(*, user_id, arguments, **_ctx):
|
||||
if "reminder_minutes" in arguments:
|
||||
rm = arguments["reminder_minutes"]
|
||||
fields["reminder_minutes"] = None if rm == 0 else rm
|
||||
for dt_field, key in (("start_dt", "start"), ("end_dt", "end")):
|
||||
val = arguments.get(key)
|
||||
if val:
|
||||
try:
|
||||
# Naive datetimes are user-local, not UTC — see
|
||||
# ``_parse_datetime_in_user_tz`` docstring.
|
||||
dt, _ = await _parse_datetime_in_user_tz(user_id, val)
|
||||
fields[dt_field] = dt
|
||||
except (ValueError, TypeError):
|
||||
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
|
||||
# Resolve start: split fields preferred, legacy `start` as fallback.
|
||||
if arguments.get("start_date") or arguments.get("start"):
|
||||
try:
|
||||
start_dt, _ = await _resolve_event_start(user_id, arguments)
|
||||
except (ValueError, TypeError) as exc:
|
||||
return {"success": False, "error": f"Invalid start: {exc}"}
|
||||
user_tz = await get_user_tz(user_id)
|
||||
weekday_err = _validate_weekday(start_dt, user_tz, arguments.get("expected_weekday"))
|
||||
if weekday_err:
|
||||
return {"success": False, "error": weekday_err}
|
||||
fields["start_dt"] = start_dt
|
||||
if arguments.get("end_date") or arguments.get("end"):
|
||||
try:
|
||||
end_dt = await _resolve_event_end(user_id, arguments)
|
||||
except (ValueError, TypeError) as exc:
|
||||
return {"success": False, "error": f"Invalid end: {exc}"}
|
||||
if end_dt is not None:
|
||||
fields["end_dt"] = end_dt
|
||||
updated = await events_update_event(user_id=user_id, event_id=event_to_update.id, **fields)
|
||||
if updated is None:
|
||||
return {"success": False, "error": "Event not found or update failed."}
|
||||
@@ -216,18 +458,29 @@ async def update_event_tool(*, user_id, arguments, **_ctx):
|
||||
|
||||
@tool(
|
||||
name="delete_event",
|
||||
description="Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.",
|
||||
description=(
|
||||
"Delete a calendar event. Use this when the user asks to cancel, "
|
||||
"remove, or delete an event. Identify the event with EITHER "
|
||||
"`query` (a title substring) OR `event_id` (when you have an "
|
||||
"exact id). If `query` matches multiple events, the tool returns "
|
||||
"an ambiguity error with a candidate list — pick one by passing "
|
||||
"its `event_id` on the next call, or refine the query so it "
|
||||
"matches a single event. Deleting the wrong event is a costly "
|
||||
"user error; never guess between candidates."
|
||||
),
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "Search term to find the event to delete (matches against title)"},
|
||||
"query": {"type": "string", "description": "Search term to find the event to delete (matches against title). Required unless event_id is set."},
|
||||
"event_id": {"type": "integer", "description": "Exact event id, used to disambiguate when a prior call returned multiple candidates. Takes precedence over query."},
|
||||
},
|
||||
required=["query"],
|
||||
required=[],
|
||||
)
|
||||
async def delete_event_tool(*, user_id, arguments, **_ctx):
|
||||
query = arguments.get("query", "")
|
||||
matches = await find_events_by_query(user_id=user_id, query=query)
|
||||
if not matches:
|
||||
return {"success": False, "error": f"No event found matching '{query}'."}
|
||||
event_to_delete = matches[0]
|
||||
resolved = await _resolve_event_for_action(
|
||||
user_id=user_id, arguments=arguments, action="delete",
|
||||
)
|
||||
if isinstance(resolved, tuple):
|
||||
return resolved[1]
|
||||
event_to_delete = resolved
|
||||
await events_delete_event(user_id=user_id, event_id=event_to_delete.id)
|
||||
return {"success": True, "type": "event_deleted", "data": {"id": event_to_delete.id, "title": event_to_delete.title}}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
@@ -15,6 +16,97 @@ from fabledassistant.services.tools._registry import tool
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Generic placeholders the model occasionally emits for `place_names` when no
|
||||
# real place was named. Filtered out server-side as belt-and-suspenders to the
|
||||
# prompt-layer guidance — these aren't places, just role-labels for locations
|
||||
# the user already has named (Home / Work weather targets, etc.).
|
||||
_PLACEHOLDER_PLACE_NAMES = frozenset({
|
||||
"work", "home", "office", "the office", "my office", "my work",
|
||||
"my home", "house", "my house", "the house",
|
||||
})
|
||||
|
||||
# Short closed-class words excluded from the keyword-overlap check below.
|
||||
# Case is normalized to lowercase before comparison.
|
||||
_STOPWORDS = frozenset({
|
||||
"the", "a", "an", "and", "or", "but", "at", "in", "on", "of", "to",
|
||||
"for", "with", "by", "from", "about", "as", "is", "was", "were", "be",
|
||||
"been", "being", "have", "has", "had", "do", "does", "did", "will",
|
||||
"would", "could", "should", "may", "might", "must", "this", "that",
|
||||
"these", "those", "i", "you", "he", "she", "we", "they", "it", "his",
|
||||
"her", "their", "my", "your", "our", "its", "me", "him", "us", "them",
|
||||
"are", "if", "so", "no", "not", "yes", "now", "then", "than", "too",
|
||||
"very", "just", "also", "any", "all", "some", "one", "two", "out",
|
||||
"up", "down", "off", "over", "under", "into", "onto", "upon",
|
||||
})
|
||||
|
||||
|
||||
def _content_keywords(text: str) -> set[str]:
|
||||
"""Tokenize text into the meaningful keyword set used for overlap checks.
|
||||
|
||||
Lowercased, alphanumeric runs only, stopwords removed, tokens shorter
|
||||
than 3 chars dropped. Numeric tokens are kept (e.g. "Branch 14" yields
|
||||
{"branch", "14"}) because they often anchor task references.
|
||||
"""
|
||||
tokens = re.split(r"[^a-z0-9]+", text.lower())
|
||||
return {t for t in tokens if len(t) >= 3 and t not in _STOPWORDS}
|
||||
|
||||
|
||||
def _filter_placeholder_places(names: list[str]) -> tuple[list[str], list[str]]:
|
||||
"""Drop generic placeholders from a `place_names` list.
|
||||
|
||||
Returns ``(kept, dropped)``. The dropped list is used purely for log
|
||||
visibility — the moment is still created, the bogus links are just
|
||||
not persisted.
|
||||
"""
|
||||
kept: list[str] = []
|
||||
dropped: list[str] = []
|
||||
for n in names:
|
||||
norm = (n or "").strip()
|
||||
if norm and norm.lower() in _PLACEHOLDER_PLACE_NAMES:
|
||||
dropped.append(norm)
|
||||
else:
|
||||
kept.append(norm)
|
||||
return kept, dropped
|
||||
|
||||
|
||||
async def _filter_task_ids_by_keyword_overlap(
|
||||
*, user_id: int, content: str, task_ids: list[int]
|
||||
) -> list[int]:
|
||||
"""Drop task links whose title shares no meaningful keyword with the
|
||||
moment content.
|
||||
|
||||
Reproducer this guards against (2026-04-27): the model emitted
|
||||
`task_titles=["Research Weston's ADHD Evaluation"]` on a moment about
|
||||
restaging Docker — the title was real (Weston's task is in the prep
|
||||
context) but the user never referenced it. Without this guard, the
|
||||
only task surfaced in the prep gets attached to every moment as
|
||||
filler. After this guard the link is dropped (zero-overlap) and a
|
||||
log entry is emitted at INFO so we can observe how often this fires.
|
||||
"""
|
||||
if not task_ids:
|
||||
return []
|
||||
async with async_session() as session:
|
||||
stmt = select(Note.id, Note.title).where(
|
||||
Note.user_id == user_id,
|
||||
Note.id.in_(task_ids),
|
||||
)
|
||||
rows = (await session.execute(stmt)).all()
|
||||
title_by_id = {nid: (title or "") for nid, title in rows}
|
||||
content_kw = _content_keywords(content)
|
||||
kept: list[int] = []
|
||||
for tid in task_ids:
|
||||
title = title_by_id.get(tid, "")
|
||||
title_kw = _content_keywords(title)
|
||||
if title_kw and content_kw & title_kw:
|
||||
kept.append(tid)
|
||||
else:
|
||||
logger.info(
|
||||
"record_moment: dropped task link id=%s title=%r — no keyword overlap with content %r",
|
||||
tid, title, content[:80],
|
||||
)
|
||||
return kept
|
||||
|
||||
|
||||
async def _resolve_entity_ids_by_name(
|
||||
*,
|
||||
user_id: int,
|
||||
@@ -73,12 +165,25 @@ async def _resolve_entity_ids_by_name(
|
||||
"STRONGLY PREFER the *_names parameters when linking entities — the server "
|
||||
"resolves names to IDs by lookup, so you cannot accidentally invent or "
|
||||
"re-use the wrong ID. Use *_ids only when you have an exact ID returned "
|
||||
"from another tool call in this same turn."
|
||||
"from another tool call in this same turn. "
|
||||
"`task_titles` and `note_titles` must be exact titles returned by a prior "
|
||||
"search_notes call in this same turn. Do NOT pass user-typed phrases, "
|
||||
"project names, or invented titles. If you have not searched yet, call "
|
||||
"search_notes first."
|
||||
),
|
||||
parameters={
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "1-2 sentence distillation of the moment in the user's voice or third-person.",
|
||||
"description": (
|
||||
"1-2 sentence distillation of the moment in the user's "
|
||||
"voice — first-person or imperative, like a journal jot. "
|
||||
"GOOD: 'Restaging Docker on the Bedford swarm; one "
|
||||
"Windows node had network breakage.' / 'Appointment "
|
||||
"Friday — details TBD.' "
|
||||
"BAD: 'The user mentioned having an appointment.' / "
|
||||
"'User reports Docker swarm restage.' Strip 'the user…' "
|
||||
"/ 'user mentioned…' framings entirely."
|
||||
),
|
||||
},
|
||||
"occurred_at": {
|
||||
"type": "string",
|
||||
@@ -171,10 +276,17 @@ async def record_moment_tool(*, user_id, arguments, conv_id=None, **_ctx):
|
||||
note_type="person",
|
||||
)
|
||||
)
|
||||
raw_place_names = arguments.get("place_names") or []
|
||||
kept_place_names, dropped_place_names = _filter_placeholder_places(raw_place_names)
|
||||
if dropped_place_names:
|
||||
logger.info(
|
||||
"record_moment: dropped placeholder place_names %r — not real places",
|
||||
dropped_place_names,
|
||||
)
|
||||
place_ids.extend(
|
||||
await _resolve_entity_ids_by_name(
|
||||
user_id=user_id,
|
||||
names=arguments.get("place_names") or [],
|
||||
names=kept_place_names,
|
||||
note_type="place",
|
||||
)
|
||||
)
|
||||
@@ -199,6 +311,15 @@ async def record_moment_tool(*, user_id, arguments, conv_id=None, **_ctx):
|
||||
task_ids = list(dict.fromkeys(task_ids))
|
||||
note_ids = list(dict.fromkeys(note_ids))
|
||||
|
||||
# Drop task links that don't share a keyword with the moment content.
|
||||
# Belt-and-suspenders to the prompt-layer rule "only link tasks the user
|
||||
# explicitly references" — if the model attaches a task anyway (because
|
||||
# it's in the prep context), the keyword check refuses to persist a link
|
||||
# the moment can't justify.
|
||||
task_ids = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=user_id, content=content, task_ids=task_ids,
|
||||
)
|
||||
|
||||
moment = await create_moment(
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
|
||||
@@ -23,11 +23,17 @@ logger = logging.getLogger(__name__)
|
||||
"Create a new note or task. "
|
||||
"For a knowledge note, omit the status field. "
|
||||
"For an actionable task (todo, reminder, action item), set status to 'todo'. "
|
||||
"Use this whenever the user asks to write down, save, record, or add a task/todo."
|
||||
"Use this whenever the user asks to write down, save, record, or add a task/todo. "
|
||||
"For standalone reusable knowledge, ALSO use this when the user explicitly asks "
|
||||
"to save something as a note / runbook / how-to, OR when their message contains "
|
||||
"a fenced code block or a numbered procedure (3+ steps) that's reusable beyond "
|
||||
"a single task. For task-specific work-in-progress, use log_work instead — that "
|
||||
"feeds the task's auto-summary."
|
||||
),
|
||||
parameters={
|
||||
"title": {"type": "string", "description": "The title"},
|
||||
"body": {"type": "string", "description": "Content in markdown"},
|
||||
"body": {"type": "string", "description": "Content in markdown. NOTE: when status is set (creating a task), body is ignored — task bodies are auto-maintained from work logs. Use `description` to provide the goal/context for tasks."},
|
||||
"description": {"type": "string", "description": "User-stated goal or initial context for a task. Read-only context for the auto-summary pipeline. Ignored when status is omitted (knowledge note)."},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": 'Tags (without # prefix, hyphens for multi-word: ["science-fiction", "story/idea"]). Do NOT embed #tags in the body.'},
|
||||
"project": {"type": "string", "description": "Optional project name. Only set this if the user explicitly named a project. Do NOT infer a project from the content or context."},
|
||||
"status": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "Set to 'todo' to create a task. Omit entirely for a knowledge note."},
|
||||
@@ -43,6 +49,7 @@ logger = logging.getLogger(__name__)
|
||||
async def create_note_tool(*, user_id, arguments, **_ctx):
|
||||
title = arguments.get("title", "Untitled")
|
||||
body = arguments.get("body", "")
|
||||
description = arguments.get("description")
|
||||
tags = arguments.get("tags", [])
|
||||
if not isinstance(title, str):
|
||||
return {"success": False, "error": "title must be a string. Call create_note once per item."}
|
||||
@@ -54,6 +61,11 @@ async def create_note_tool(*, user_id, arguments, **_ctx):
|
||||
is_task = "status" in arguments and arguments["status"] is not None
|
||||
status = arguments.get("status", "todo") if is_task else None
|
||||
|
||||
# Task bodies are auto-maintained by the consolidation pipeline; drop any
|
||||
# body argument arriving with a task creation so it never lands in the DB.
|
||||
if is_task:
|
||||
body = ""
|
||||
|
||||
project_name = arguments.get("project")
|
||||
milestone_name = arguments.get("milestone")
|
||||
parent_task_name = arguments.get("parent_task")
|
||||
@@ -80,6 +92,7 @@ async def create_note_tool(*, user_id, arguments, **_ctx):
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
body=body,
|
||||
description=description,
|
||||
tags=tags,
|
||||
status=status,
|
||||
priority=arguments.get("priority", "none") if is_task else None,
|
||||
@@ -126,7 +139,8 @@ async def create_note_tool(*, user_id, arguments, **_ctx):
|
||||
description="Update an existing note or task — content, title, status, priority, or due date. Use for edits, marking tasks done, changing priority. Never use create_note for existing notes.",
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "Title or keyword to find the note or task to update"},
|
||||
"body": {"type": "string", "description": "New note content in markdown (omit if only updating task fields)"},
|
||||
"body": {"type": "string", "description": "New note content in markdown (omit if only updating task fields). REJECTED on tasks — task bodies are auto-maintained from work logs; use `log_work` to record progress or `description` to revise the goal."},
|
||||
"description": {"type": "string", "description": "Update the user-stated goal/context (tasks). Distinct from `body` (machine-maintained on tasks)."},
|
||||
"title": {"type": "string", "description": "Optional new title"},
|
||||
"mode": {"type": "string", "enum": ["replace", "append"], "description": "How to apply the new body: 'replace' overwrites existing content (default), 'append' adds after existing content"},
|
||||
"status": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "New task status. Use to mark a task done, start it, cancel it, etc."},
|
||||
@@ -155,6 +169,19 @@ async def update_note_tool(*, user_id, arguments, **_ctx):
|
||||
return {"success": False, "error": f"No note found matching '{query}'."}
|
||||
note = candidates[0]
|
||||
|
||||
# Schema-level separation: task bodies are auto-maintained from work logs.
|
||||
# Reject body writes here rather than silently dropping so the LLM gets
|
||||
# nudged toward log_work / description.
|
||||
if note.is_task and arguments.get("body"):
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"Cannot write to `body` on a task — the body is auto-maintained "
|
||||
"from work logs. Use the `log_work` tool to record progress, or "
|
||||
"update `description` to revise the goal."
|
||||
),
|
||||
}
|
||||
|
||||
update_fields: dict = {}
|
||||
if new_title:
|
||||
update_fields["title"] = new_title
|
||||
@@ -163,6 +190,8 @@ async def update_note_tool(*, user_id, arguments, **_ctx):
|
||||
update_fields["body"] = note.body + "\n\n" + new_body
|
||||
else:
|
||||
update_fields["body"] = new_body
|
||||
if "description" in arguments:
|
||||
update_fields["description"] = arguments["description"]
|
||||
if "status" in arguments:
|
||||
update_fields["status"] = arguments["status"]
|
||||
if "priority" in arguments:
|
||||
@@ -235,7 +264,13 @@ async def update_note_tool(*, user_id, arguments, **_ctx):
|
||||
|
||||
@tool(
|
||||
name="search_notes",
|
||||
description="Find notes or tasks by meaning. Returns a ranked list of matches with short previews. Use this when looking for items on a topic but you don't know the exact title. For the full body of a known item, use read_note instead.",
|
||||
description=(
|
||||
"Find notes or tasks by meaning. Returns a ranked list of matches with "
|
||||
"short previews. Use this when looking for items on a topic but you "
|
||||
"don't know the exact title. For the full body of a known item, use "
|
||||
"read_note instead. Do not include 'task', 'note', or 'project' in the "
|
||||
"`query` — use the `type` and `project` parameters instead."
|
||||
),
|
||||
parameters={
|
||||
"query": {"type": "string", "description": "A natural-language description of what you're looking for — concepts, themes, topics, or keywords"},
|
||||
"type": {"type": "string", "enum": ["note", "task"], "description": "Restrict results to only notes or only tasks. Omit to search both."},
|
||||
|
||||
@@ -135,20 +135,12 @@ async def update_project_tool(*, user_id, arguments, **_ctx):
|
||||
briefing=True,
|
||||
)
|
||||
async def search_projects_tool(*, user_id, arguments, **_ctx):
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from fabledassistant.services.projects import list_projects
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
|
||||
query = str(arguments.get("query", "")).lower()
|
||||
query = str(arguments.get("query", ""))
|
||||
projects = await list_projects(user_id)
|
||||
scored: list[tuple[float, object]] = []
|
||||
for p in projects:
|
||||
combined = f"{p.title} {p.description or ''} {p.auto_summary or ''}".lower()
|
||||
base_score = SequenceMatcher(None, query, combined).ratio()
|
||||
query_words = set(query.split())
|
||||
overlap = sum(1 for w in query_words if w in combined)
|
||||
score = base_score + overlap * 0.05
|
||||
scored.append((score, p))
|
||||
scored: list[tuple[float, object]] = [(score_project_match(query, p), p) for p in projects]
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
results = []
|
||||
for score, p in scored[:5]:
|
||||
@@ -158,7 +150,7 @@ async def search_projects_tool(*, user_id, arguments, **_ctx):
|
||||
"summary_snippet": (p.auto_summary or p.description or "")[:200],
|
||||
"score": round(score, 3),
|
||||
})
|
||||
return {"type": "projects_list", "data": {"projects": results}}
|
||||
return {"success": True, "type": "projects_list", "data": {"projects": results}}
|
||||
|
||||
|
||||
@tool(
|
||||
|
||||
@@ -80,7 +80,14 @@ async def list_tasks_tool(*, user_id, arguments, **_ctx):
|
||||
|
||||
@tool(
|
||||
name="log_work",
|
||||
description="Add a work log entry to a task to record progress, work done, or time spent. Use this when the user says they worked on, completed, or spent time on a task.",
|
||||
description=(
|
||||
"Add a work log entry to a task to record progress, work done, or time spent. "
|
||||
"Use this when the user says they worked on, completed, or spent time on a task. "
|
||||
"Work logs feed the task's auto-summary: every few entries the task body is "
|
||||
"rewritten from the logs by a background pass. Be specific (commands run, "
|
||||
"decisions made, what failed vs. what worked) — the summary is only as good "
|
||||
"as the logs."
|
||||
),
|
||||
parameters={
|
||||
"task": {"type": "string", "description": "Title or keyword identifying the task (required)"},
|
||||
"content": {"type": "string", "description": "Description of the work done (required)"},
|
||||
@@ -113,4 +120,8 @@ async def log_work_tool(*, user_id, arguments, **_ctx):
|
||||
log = await _create_log(user_id, note.id, content, duration_minutes)
|
||||
except ValueError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
from fabledassistant.services.consolidation import maybe_consolidate
|
||||
await maybe_consolidate(user_id, note.id, reason="log_added")
|
||||
|
||||
return {"success": True, "log": log.to_dict(), "task": note.title}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Pin/unpin and auto-pin operations on NoteVersion.
|
||||
|
||||
The autosave-rolling system in `services/note_versions.py` continues to
|
||||
own the existing rolling-cap behavior; this module adds the manual-pin
|
||||
API and the daily auto-pin scan.
|
||||
|
||||
Tiers (pin_kind values):
|
||||
None → rolling autosave; capped at MAX_VERSIONS, FIFO.
|
||||
"auto" → system-declared via the stability scan; capped at MAX_AUTO_PINS, FIFO.
|
||||
"manual" → user-declared; unlimited, never pruned.
|
||||
|
||||
Design: docs/superpowers/specs/2026-05-13-note-version-pinning-design.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from datetime import timezone
|
||||
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.note_version import NoteVersion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_AUTO_PINS = 25
|
||||
AUTO_PIN_STABILITY_DAYS = 2
|
||||
PIN_LABEL_MAX_LEN = 500
|
||||
|
||||
|
||||
async def pin_version(
|
||||
user_id: int, note_id: int, version_id: int, *, label: str | None,
|
||||
) -> NoteVersion | None:
|
||||
"""Mark a version as manually pinned. Returns the updated row or None
|
||||
if not found (or wrong user/note scope).
|
||||
|
||||
Acceptable on already-pinned rows (manual or auto) — promotes to
|
||||
manual and updates the label. Labels are capped at PIN_LABEL_MAX_LEN
|
||||
chars; longer values raise ValueError.
|
||||
"""
|
||||
if label is not None and len(label) > PIN_LABEL_MAX_LEN:
|
||||
raise ValueError(
|
||||
f"pin_label too long ({len(label)} > {PIN_LABEL_MAX_LEN} chars)"
|
||||
)
|
||||
async with async_session() as session:
|
||||
version = (
|
||||
await session.execute(
|
||||
select(NoteVersion).where(
|
||||
NoteVersion.id == version_id,
|
||||
NoteVersion.note_id == note_id,
|
||||
NoteVersion.user_id == user_id,
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if version is None:
|
||||
return None
|
||||
version.pin_kind = "manual"
|
||||
version.pin_label = label
|
||||
await session.commit()
|
||||
await session.refresh(version)
|
||||
return version
|
||||
|
||||
|
||||
async def unpin_version(
|
||||
user_id: int, note_id: int, version_id: int,
|
||||
) -> NoteVersion | None:
|
||||
"""Clear pin_kind and pin_label, downgrading the row to rolling.
|
||||
|
||||
Does NOT delete the row. If the row is older than the rolling cap
|
||||
depth, the next create_version call will prune it via the rolling
|
||||
FIFO. Returns the updated row or None if not found.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
version = (
|
||||
await session.execute(
|
||||
select(NoteVersion).where(
|
||||
NoteVersion.id == version_id,
|
||||
NoteVersion.note_id == note_id,
|
||||
NoteVersion.user_id == user_id,
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if version is None:
|
||||
return None
|
||||
version.pin_kind = None
|
||||
version.pin_label = None
|
||||
await session.commit()
|
||||
await session.refresh(version)
|
||||
return version
|
||||
|
||||
|
||||
def _format_auto_pin_label(
|
||||
start: datetime.datetime, end: datetime.datetime | None,
|
||||
) -> str:
|
||||
"""Auto-generated label describing the stability window.
|
||||
|
||||
end=None → version is the latest with no successor; render as
|
||||
"stable since {start_iso}". Otherwise render as
|
||||
"stable {start_iso} → {end_iso}".
|
||||
"""
|
||||
s = start.date().isoformat()
|
||||
if end is None:
|
||||
return f"stable since {s}"
|
||||
return f"stable {s} → {end.date().isoformat()}"
|
||||
|
||||
|
||||
def _promote_stable_versions_for_note(versions_chrono: list) -> list:
|
||||
"""Mutate the input list: set pin_kind='auto' + auto-label on any
|
||||
unpinned version whose gap to its successor (or to now, for the
|
||||
latest) is >= AUTO_PIN_STABILITY_DAYS.
|
||||
|
||||
Returns the list of versions that were newly pinned (caller uses
|
||||
this for logging / counts).
|
||||
"""
|
||||
now = datetime.datetime.now(timezone.utc)
|
||||
newly_pinned: list = []
|
||||
for i, v in enumerate(versions_chrono):
|
||||
if v.pin_kind is not None:
|
||||
continue
|
||||
if i + 1 < len(versions_chrono):
|
||||
next_ts = versions_chrono[i + 1].created_at
|
||||
is_latest = False
|
||||
else:
|
||||
next_ts = now
|
||||
is_latest = True
|
||||
v_ts = v.created_at
|
||||
if v_ts.tzinfo is None:
|
||||
v_ts = v_ts.replace(tzinfo=timezone.utc)
|
||||
if next_ts.tzinfo is None:
|
||||
next_ts = next_ts.replace(tzinfo=timezone.utc)
|
||||
gap_days = (next_ts - v_ts).total_seconds() / 86400
|
||||
if gap_days >= AUTO_PIN_STABILITY_DAYS:
|
||||
v.pin_kind = "auto"
|
||||
v.pin_label = _format_auto_pin_label(
|
||||
v_ts, None if is_latest else next_ts,
|
||||
)
|
||||
newly_pinned.append(v)
|
||||
return newly_pinned
|
||||
|
||||
|
||||
async def _list_user_note_ids_with_versions(user_id: int) -> list[int]:
|
||||
"""Return note_ids belonging to user_id that have at least one row in
|
||||
note_versions. Notes with no version history are ignored."""
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT note_id FROM note_versions
|
||||
WHERE user_id = :user_id
|
||||
"""
|
||||
).bindparams(user_id=user_id)
|
||||
)
|
||||
).all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
async def _scan_one_note(user_id: int, note_id: int) -> int:
|
||||
"""Run the promote+prune flow for one note. Returns count of newly-
|
||||
pinned versions."""
|
||||
async with async_session() as session:
|
||||
versions = (
|
||||
await session.execute(
|
||||
select(NoteVersion)
|
||||
.where(
|
||||
NoteVersion.note_id == note_id,
|
||||
NoteVersion.user_id == user_id,
|
||||
)
|
||||
.order_by(NoteVersion.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
if not versions:
|
||||
return 0
|
||||
newly_pinned = _promote_stable_versions_for_note(list(versions))
|
||||
if newly_pinned:
|
||||
await session.commit()
|
||||
if newly_pinned:
|
||||
await prune_auto_pins(user_id=user_id, note_id=note_id)
|
||||
return len(newly_pinned)
|
||||
|
||||
|
||||
async def scan_user_for_auto_pins(user_id: int) -> int:
|
||||
"""Run the scan across every versioned note for one user. Returns
|
||||
total newly-pinned-this-pass."""
|
||||
total = 0
|
||||
note_ids = await _list_user_note_ids_with_versions(user_id)
|
||||
for note_id in note_ids:
|
||||
try:
|
||||
total += await _scan_one_note(user_id, note_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"auto-pin scan failed for user=%d note=%d", user_id, note_id,
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
async def scan_all_users_for_auto_pins() -> dict[int, int]:
|
||||
"""Top-level scan entrypoint. Iterates over all users and runs the
|
||||
per-user scan. Returns {user_id: newly_pinned_count}. Per-user errors
|
||||
are caught and logged so one user's failure doesn't stop the scan."""
|
||||
from fabledassistant.models import User
|
||||
|
||||
async with async_session() as session:
|
||||
users = (await session.execute(select(User.id))).scalars().all()
|
||||
|
||||
out: dict[int, int] = {}
|
||||
for uid in users:
|
||||
try:
|
||||
out[uid] = await scan_user_for_auto_pins(uid)
|
||||
except Exception:
|
||||
logger.exception("auto-pin scan failed for user=%d", uid)
|
||||
out[uid] = 0
|
||||
return out
|
||||
|
||||
|
||||
async def prune_auto_pins(user_id: int, note_id: int) -> None:
|
||||
"""FIFO-prune the auto-pinned bucket for one note past MAX_AUTO_PINS.
|
||||
|
||||
Manual pins and rolling rows are untouched. Called by the scan job
|
||||
after each note's auto-pin promotions finish.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM note_versions
|
||||
WHERE id IN (
|
||||
SELECT id FROM note_versions
|
||||
WHERE note_id = :note_id
|
||||
AND user_id = :user_id
|
||||
AND pin_kind = 'auto'
|
||||
ORDER BY created_at DESC
|
||||
OFFSET :max_auto_pins
|
||||
)
|
||||
"""
|
||||
).bindparams(
|
||||
note_id=note_id,
|
||||
user_id=user_id,
|
||||
max_auto_pins=MAX_AUTO_PINS,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Daily APScheduler cron for the auto-pin scan.
|
||||
|
||||
Single global job at 03:00 UTC. Runs scan_all_users_for_auto_pins so the
|
||||
system promotes stable note versions before they get aged out of the
|
||||
rolling cap. Off-hours by design — the scan is cheap but not time-
|
||||
critical and doesn't need to interrupt regular activity.
|
||||
|
||||
Mirrors the BackgroundScheduler + threadsafe-async-call pattern used by
|
||||
journal_scheduler.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from fabledassistant.services.version_pinning import scan_all_users_for_auto_pins
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
def _run_scan_threadsafe() -> None:
|
||||
"""APScheduler invokes this from a worker thread; bridge into the
|
||||
asyncio loop so the scan can await its DB operations."""
|
||||
if _loop is None:
|
||||
logger.warning("version_pinning scheduler: no loop registered")
|
||||
return
|
||||
|
||||
async def _runner():
|
||||
try:
|
||||
results = await scan_all_users_for_auto_pins()
|
||||
total = sum(results.values())
|
||||
if total > 0:
|
||||
logger.info(
|
||||
"auto-pin scan: pinned %d version(s) across %d user(s)",
|
||||
total, len(results),
|
||||
)
|
||||
else:
|
||||
logger.debug("auto-pin scan: no new pins")
|
||||
except Exception:
|
||||
logger.exception("auto-pin scan run failed")
|
||||
|
||||
asyncio.run_coroutine_threadsafe(_runner(), _loop)
|
||||
|
||||
|
||||
def start_version_pinning_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
global _scheduler, _loop
|
||||
if _scheduler is not None:
|
||||
return
|
||||
_loop = loop
|
||||
_scheduler = BackgroundScheduler()
|
||||
_scheduler.add_job(
|
||||
_run_scan_threadsafe,
|
||||
trigger=CronTrigger(hour=3, minute=0, timezone="UTC"),
|
||||
id="version_pinning_auto_scan",
|
||||
replace_existing=True,
|
||||
)
|
||||
_scheduler.start()
|
||||
logger.info("Version pinning scheduler started (daily 03:00 UTC)")
|
||||
|
||||
|
||||
def stop_version_pinning_scheduler() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
logger.info("Version pinning scheduler stopped")
|
||||
@@ -156,15 +156,13 @@ def parse_weather_card_data(
|
||||
) -> dict | None:
|
||||
"""
|
||||
Parse a WeatherCache row into the metadata.weather card schema.
|
||||
Returns None if the cache is stale (older than 24 hours) or unavailable.
|
||||
Returns None if the cache row is missing or unparseable. Stale data is
|
||||
returned as-is — the frontend uses `fetched_at` to convey freshness, and
|
||||
WeatherCard handles missing today/forecast fields gracefully.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
|
||||
if cache_row is None or cache_row.fetched_at is None:
|
||||
return None
|
||||
|
||||
age_seconds = (datetime.now(timezone.utc) - cache_row.fetched_at).total_seconds()
|
||||
if age_seconds > 86400:
|
||||
if cache_row is None or cache_row.fetched_at is None or not cache_row.forecast_json:
|
||||
return None
|
||||
|
||||
raw = cache_row.forecast_json or {}
|
||||
@@ -234,12 +232,23 @@ def parse_weather_card_data(
|
||||
}
|
||||
|
||||
|
||||
async def get_cached_weather_rows(user_id: int) -> list:
|
||||
"""Return raw WeatherCache ORM rows for a user (for card parsing)."""
|
||||
async def get_cached_weather_rows(
|
||||
user_id: int,
|
||||
valid_keys: set[str] | None = None,
|
||||
) -> list:
|
||||
"""Return raw WeatherCache ORM rows for a user (for card parsing).
|
||||
|
||||
If ``valid_keys`` is provided, only rows whose ``location_key`` is in the
|
||||
set are returned. This is how callers drop orphaned cache rows whose
|
||||
location is no longer in the user's ``journal_config.locations`` (e.g.
|
||||
leftovers from the briefing era, or a location that's been removed).
|
||||
Passing an empty set returns no rows.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WeatherCache).where(WeatherCache.user_id == user_id)
|
||||
)
|
||||
stmt = select(WeatherCache).where(WeatherCache.user_id == user_id)
|
||||
if valid_keys is not None:
|
||||
stmt = stmt.where(WeatherCache.location_key.in_(list(valid_keys)))
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
|
||||
@@ -132,3 +132,664 @@ async def test_list_events_bare_date_range_covers_local_day():
|
||||
assert (df.year, df.month, df.day, df.hour) == (2026, 9, 30, 4)
|
||||
# 2026-09-30 23:59:59 NY (EDT) = 03:59:59 UTC next day
|
||||
assert (dt.year, dt.month, dt.day, dt.hour) == (2026, 10, 1, 3)
|
||||
|
||||
|
||||
# ── Split date/time field tests (durable shape) ──────────────────────────────
|
||||
#
|
||||
# These exercise the start_date + start_time path that the model is now
|
||||
# steered toward. The split-field shape is structurally immune to the
|
||||
# class of bugs where a model emits a TZ-tagged combined datetime that
|
||||
# the parser correctly honors but lands on the wrong calendar day for
|
||||
# negative-offset users.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_fields_friday_8am_no_drift_eastern():
|
||||
"""The reported bug: 'next Friday at 8am' for a NY user must land on
|
||||
2026-05-01 08:00 NY, never 04-30 19:00. With split fields the model
|
||||
can't TZ-tag the date string, so the calendar day is fixed."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Meeting",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
# 08:00 NY (EDT, UTC-4) on 2026-05-01 = 12:00 UTC same day
|
||||
assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 5, 1, 12, 0)
|
||||
assert captured["all_day"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_fields_no_drift_pacific():
|
||||
"""Same scenario for a UTC-8 user — the calendar day must be 5/1
|
||||
regardless of how big the offset gets."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/Los_Angeles")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Standup",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
# 08:00 LA (PDT, UTC-7) = 15:00 UTC same day
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 15)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_fields_no_drift_positive_offset():
|
||||
"""A positive-offset user (Tokyo, UTC+9) — 08:00 Tokyo on 2026-05-01
|
||||
is 23:00 UTC on 2026-04-30, but the local calendar day must still be
|
||||
stored as 2026-05-01 from the user's perspective."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("Asia/Tokyo")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Sync",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
# 08:00 Tokyo (UTC+9) = 23:00 UTC previous day; the round-trip back
|
||||
# to Tokyo TZ recovers 2026-05-01 08:00.
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 4, 30, 23)
|
||||
tokyo = captured["start_dt"].astimezone(__import__("zoneinfo").ZoneInfo("Asia/Tokyo"))
|
||||
assert (tokyo.year, tokyo.month, tokyo.day, tokyo.hour) == (2026, 5, 1, 8)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_date_only_is_all_day():
|
||||
"""Omitting start_time means the event is all-day; cache row uses
|
||||
local midnight."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Birthday", "start_date": "2026-09-30"},
|
||||
)
|
||||
|
||||
assert captured["all_day"] is True
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 9, 30, 4)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_rejects_tz_suffix_in_date():
|
||||
"""The whole point of split fields: a TZ suffix on the date string
|
||||
must be rejected, not silently honored."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "x", "start_date": "2026-05-01Z", "start_time": "08:00"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "YYYY-MM-DD" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_split_rejects_tz_suffix_in_time():
|
||||
"""A TZ suffix on the time string must also be rejected."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "x", "start_date": "2026-05-01", "start_time": "08:00 UTC"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "HH:MM" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_legacy_combined_start_still_works():
|
||||
"""Backcompat: saved tool-call payloads using the old `start` field
|
||||
must still produce the same UTC datetime as before."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={"title": "Legacy", "start": "2026-05-01T08:00"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 12)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_split_fields_reschedule_no_drift():
|
||||
"""update_event with split fields must drift no calendar day either."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
ev = AsyncMock()
|
||||
ev.id = 99
|
||||
ev.title = "Coffee"
|
||||
return [ev]
|
||||
|
||||
async def fake_update(*, user_id, event_id, **fields):
|
||||
captured.update(fields)
|
||||
ev = AsyncMock()
|
||||
ev.to_dict.return_value = {"id": event_id, **fields}
|
||||
return ev
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_update_event",
|
||||
side_effect=fake_update,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"query": "Coffee",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||
assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 12)
|
||||
|
||||
|
||||
# ── expected_weekday verification (catches "this Friday" → Thursday bugs) ────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_expected_weekday_match_succeeds():
|
||||
"""When expected_weekday agrees with the resolved local date's
|
||||
weekday, the event is created normally."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Meeting",
|
||||
"start_date": "2026-05-01", # is a Friday
|
||||
"start_time": "08:00",
|
||||
"expected_weekday": "friday",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["start_dt"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_expected_weekday_mismatch_rejects_and_names_actual():
|
||||
"""The reported failure mode: model picks Thursday and calls it
|
||||
Friday. With expected_weekday set, the create is rejected and the
|
||||
error names the actual weekday so the model can self-correct."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
create_called = False
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
nonlocal create_called
|
||||
create_called = True
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Dentist",
|
||||
"start_date": "2026-04-30", # Thursday
|
||||
"start_time": "08:00",
|
||||
"expected_weekday": "friday",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Thursday" in result["error"]
|
||||
assert "Friday" in result["error"]
|
||||
# Critical: the event must NOT have been created when the check failed.
|
||||
assert create_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_expected_weekday_omitted_skips_check():
|
||||
"""Backcompat: when expected_weekday isn't passed, no validation
|
||||
runs — the existing create flow is unchanged."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "x",
|
||||
"start_date": "2026-04-30",
|
||||
"start_time": "08:00",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_expected_weekday_invalid_value_rejects():
|
||||
"""Garbage in expected_weekday produces a clear validation error,
|
||||
not a silent pass."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "x",
|
||||
"start_date": "2026-05-01",
|
||||
"start_time": "08:00",
|
||||
"expected_weekday": "fri", # abbreviation not accepted
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "weekday" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_expected_weekday_mismatch_rejects():
|
||||
"""update_event must enforce the same weekday check on reschedules."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
update_called = False
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
ev = AsyncMock()
|
||||
ev.id = 99
|
||||
ev.title = "Coffee"
|
||||
return [ev]
|
||||
|
||||
async def fake_update(*, user_id, event_id, **fields):
|
||||
nonlocal update_called
|
||||
update_called = True
|
||||
ev = AsyncMock()
|
||||
ev.to_dict.return_value = {"id": event_id}
|
||||
return ev
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_update_event",
|
||||
side_effect=fake_update,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"query": "Coffee",
|
||||
"start_date": "2026-04-30", # Thursday
|
||||
"start_time": "08:00",
|
||||
"expected_weekday": "friday",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Thursday" in result["error"]
|
||||
assert update_called is False
|
||||
|
||||
|
||||
# ── Multi-match disambiguation (Fable #161) ──────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_refuses_ambiguous_query_with_candidates():
|
||||
"""The reported failure: model called update_event(query='Appointment')
|
||||
when two events matched. Pre-fix: silently mutated matches[0] (the
|
||||
wrong event). Post-fix: returns success=False with a candidates list
|
||||
so the model can pick the right one."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
# Two events both matching "Appointment"
|
||||
ev_a = AsyncMock()
|
||||
ev_a.id = 2
|
||||
ev_a.title = "Appointment"
|
||||
ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
|
||||
ev_a.location = ""
|
||||
ev_b = AsyncMock()
|
||||
ev_b.id = 15
|
||||
ev_b.title = "Appointment"
|
||||
ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
ev_b.location = "Dentist"
|
||||
|
||||
update_called = False
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
return [ev_a, ev_b]
|
||||
|
||||
async def fake_update(*, user_id, event_id, **fields):
|
||||
nonlocal update_called
|
||||
update_called = True
|
||||
return AsyncMock()
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_update_event",
|
||||
side_effect=fake_update,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={"query": "Appointment", "title": "Dentist"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Found 2 events" in result["error"]
|
||||
assert "event_id" in result["error"].lower()
|
||||
assert "candidates" in result
|
||||
candidate_ids = [c["id"] for c in result["candidates"]]
|
||||
assert candidate_ids == [2, 15]
|
||||
# Critical: nothing was mutated.
|
||||
assert update_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_with_event_id_skips_query_lookup():
|
||||
"""Once the model picks a candidate via event_id, the call proceeds
|
||||
against that exact event — no query, no ambiguity."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
# find_events_by_query should NOT be called when event_id is supplied
|
||||
find_called = False
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
nonlocal find_called
|
||||
find_called = True
|
||||
return []
|
||||
|
||||
target = AsyncMock()
|
||||
target.id = 15
|
||||
target.title = "Appointment"
|
||||
target.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
target.location = ""
|
||||
|
||||
async def fake_get_event(*, user_id, event_id):
|
||||
return target if event_id == 15 else None
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_update(*, user_id, event_id, **fields):
|
||||
captured["event_id"] = event_id
|
||||
captured.update(fields)
|
||||
ev = AsyncMock()
|
||||
ev.to_dict.return_value = {"id": event_id, **fields}
|
||||
return ev
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.events.get_event",
|
||||
side_effect=fake_get_event,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_update_event",
|
||||
side_effect=fake_update,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={"event_id": 15, "title": "Dentist: Permanent Crown Fitting"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["event_id"] == 15
|
||||
assert captured["title"] == "Dentist: Permanent Crown Fitting"
|
||||
assert find_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_with_event_id_not_found_returns_error():
|
||||
"""A bad event_id (non-existent) must surface clearly, not silently
|
||||
fall back to query-based lookup."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
async def fake_get_event(*, user_id, event_id):
|
||||
return None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.events.get_event",
|
||||
side_effect=fake_get_event,
|
||||
):
|
||||
result = await update_event_tool(
|
||||
user_id=1,
|
||||
arguments={"event_id": 999, "title": "anything"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "999" in result["error"]
|
||||
assert "No event found" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_neither_query_nor_event_id_returns_error():
|
||||
"""Calling update_event with neither identifier is a usage error."""
|
||||
from fabledassistant.services.tools.calendar import update_event_tool
|
||||
|
||||
result = await update_event_tool(user_id=1, arguments={"title": "x"})
|
||||
assert result["success"] is False
|
||||
assert "query or event_id" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_event_refuses_ambiguous_query_with_candidates():
|
||||
"""delete_event must enforce the same disambiguation. Costly to get
|
||||
wrong — silent matches[0] would delete the wrong event entirely."""
|
||||
from fabledassistant.services.tools.calendar import delete_event_tool
|
||||
|
||||
ev_a = AsyncMock()
|
||||
ev_a.id = 2; ev_a.title = "Appointment"
|
||||
ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
|
||||
ev_a.location = ""
|
||||
ev_b = AsyncMock()
|
||||
ev_b.id = 15; ev_b.title = "Appointment"
|
||||
ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
ev_b.location = "Dentist"
|
||||
|
||||
delete_called = False
|
||||
|
||||
async def fake_find(*, user_id, query):
|
||||
return [ev_a, ev_b]
|
||||
|
||||
async def fake_delete(*, user_id, event_id):
|
||||
nonlocal delete_called
|
||||
delete_called = True
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.find_events_by_query",
|
||||
side_effect=fake_find,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_delete_event",
|
||||
side_effect=fake_delete,
|
||||
):
|
||||
result = await delete_event_tool(
|
||||
user_id=1, arguments={"query": "Appointment"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert len(result["candidates"]) == 2
|
||||
# Most important assertion: nothing was actually deleted.
|
||||
assert delete_called is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_weekday_check_uses_local_not_utc():
|
||||
"""The weekday check must use the LOCAL date, not the UTC date.
|
||||
A late-evening Friday event in Tokyo (UTC+9) crosses midnight UTC,
|
||||
so a UTC-day check would call it Saturday — but the user's calendar
|
||||
says Friday. The check must respect the user's local view."""
|
||||
from fabledassistant.services.tools.calendar import create_event_tool
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_create_event(**kwargs):
|
||||
captured.update(kwargs)
|
||||
event = AsyncMock()
|
||||
event.to_dict.return_value = {"id": 1}
|
||||
return event
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("Asia/Tokyo")),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.calendar.events_create_event",
|
||||
side_effect=fake_create_event,
|
||||
):
|
||||
result = await create_event_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "Friday night",
|
||||
"start_date": "2026-05-01", # Friday in Tokyo
|
||||
"start_time": "23:00", # 14:00 UTC same day; safe
|
||||
"expected_weekday": "friday",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Tests for the task-body consolidation pipeline (gate + full pass).
|
||||
|
||||
Design: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
# ── Gate logic ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_maybe_consolidate_below_threshold_does_not_fire():
|
||||
"""log_added with only 2 logs since last pass → no consolidation."""
|
||||
from fabledassistant.services import consolidation
|
||||
|
||||
with patch.object(
|
||||
consolidation, "_logs_since_last_consolidation",
|
||||
new=AsyncMock(return_value=2),
|
||||
), patch.object(
|
||||
consolidation, "consolidate_task", new=AsyncMock(),
|
||||
) as mock_consolidate, patch.object(
|
||||
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
|
||||
):
|
||||
await consolidation.maybe_consolidate(
|
||||
user_id=1, task_id=42, reason="log_added",
|
||||
)
|
||||
|
||||
mock_consolidate.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_maybe_consolidate_at_threshold_fires():
|
||||
"""log_added with N logs since last pass → consolidation scheduled."""
|
||||
from fabledassistant.services import consolidation
|
||||
|
||||
# asyncio.create_task is the actual scheduler; replace it so the test
|
||||
# doesn't need an event-loop background task to settle.
|
||||
with patch.object(
|
||||
consolidation, "_logs_since_last_consolidation",
|
||||
new=AsyncMock(return_value=3),
|
||||
), patch.object(
|
||||
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.asyncio.create_task",
|
||||
) as mock_create_task:
|
||||
await consolidation.maybe_consolidate(
|
||||
user_id=1, task_id=42, reason="log_added",
|
||||
)
|
||||
|
||||
mock_create_task.assert_called_once()
|
||||
|
||||
|
||||
async def test_maybe_consolidate_task_closed_always_fires():
|
||||
"""task_closed bypasses the log-count gate."""
|
||||
from fabledassistant.services import consolidation
|
||||
|
||||
with patch.object(
|
||||
consolidation, "_logs_since_last_consolidation",
|
||||
new=AsyncMock(return_value=0),
|
||||
), patch.object(
|
||||
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.asyncio.create_task",
|
||||
) as mock_create_task:
|
||||
await consolidation.maybe_consolidate(
|
||||
user_id=1, task_id=42, reason="task_closed",
|
||||
)
|
||||
|
||||
mock_create_task.assert_called_once()
|
||||
|
||||
|
||||
async def test_maybe_consolidate_setting_off_blocks_both_reasons():
|
||||
"""auto_consolidate_tasks=false → neither trigger fires."""
|
||||
from fabledassistant.services import consolidation
|
||||
|
||||
with patch.object(
|
||||
consolidation, "_logs_since_last_consolidation",
|
||||
new=AsyncMock(return_value=5),
|
||||
), patch.object(
|
||||
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=False),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.asyncio.create_task",
|
||||
) as mock_create_task:
|
||||
await consolidation.maybe_consolidate(
|
||||
user_id=1, task_id=42, reason="log_added",
|
||||
)
|
||||
await consolidation.maybe_consolidate(
|
||||
user_id=1, task_id=42, reason="task_closed",
|
||||
)
|
||||
|
||||
mock_create_task.assert_not_called()
|
||||
|
||||
|
||||
async def test_maybe_consolidate_unknown_reason_is_noop():
|
||||
"""Unknown reasons get logged and skipped — not raised."""
|
||||
from fabledassistant.services import consolidation
|
||||
|
||||
with patch.object(
|
||||
consolidation, "_logs_since_last_consolidation",
|
||||
new=AsyncMock(return_value=99),
|
||||
), patch.object(
|
||||
consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.asyncio.create_task",
|
||||
) as mock_create_task:
|
||||
await consolidation.maybe_consolidate(
|
||||
user_id=1, task_id=42, reason="some_other_reason",
|
||||
)
|
||||
|
||||
mock_create_task.assert_not_called()
|
||||
|
||||
|
||||
# ── Prompt builder ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_build_consolidation_prompt_includes_title_goal_and_logs():
|
||||
from fabledassistant.services.consolidation import _build_consolidation_prompt
|
||||
|
||||
logs = [
|
||||
SimpleNamespace(
|
||||
content="tried REJECT, logs noisy",
|
||||
created_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
SimpleNamespace(
|
||||
content="switched to DROP",
|
||||
created_at=datetime(2026, 5, 13, 11, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
prompt = _build_consolidation_prompt(
|
||||
title="firewall tuning", description="quiet the logs", logs=logs,
|
||||
)
|
||||
assert "firewall tuning" in prompt
|
||||
assert "quiet the logs" in prompt
|
||||
assert "tried REJECT" in prompt
|
||||
assert "switched to DROP" in prompt
|
||||
|
||||
|
||||
def test_build_consolidation_prompt_handles_missing_description():
|
||||
from fabledassistant.services.consolidation import _build_consolidation_prompt
|
||||
|
||||
logs = [
|
||||
SimpleNamespace(
|
||||
content="a", created_at=datetime(2026, 5, 13, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
prompt = _build_consolidation_prompt(title="t", description=None, logs=logs)
|
||||
assert "no goal recorded" in prompt
|
||||
|
||||
|
||||
def test_build_consolidation_prompt_truncates_to_char_budget():
|
||||
from fabledassistant.services.consolidation import (
|
||||
_build_consolidation_prompt, MAX_PROMPT_INPUT_CHARS,
|
||||
)
|
||||
|
||||
big = "x" * (MAX_PROMPT_INPUT_CHARS + 1000)
|
||||
logs = [
|
||||
SimpleNamespace(
|
||||
content=big, created_at=datetime(2026, 5, 13, tzinfo=timezone.utc),
|
||||
),
|
||||
SimpleNamespace(
|
||||
content="should be dropped",
|
||||
created_at=datetime(2026, 5, 13, 1, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
prompt = _build_consolidation_prompt(title="t", description=None, logs=logs)
|
||||
# First log consumes the budget; the second is dropped.
|
||||
assert "should be dropped" not in prompt
|
||||
|
||||
|
||||
# ── consolidate_task orchestration (mocked DB + LLM + embedding) ─────────────
|
||||
|
||||
|
||||
def _make_mock_session(task, logs):
|
||||
"""Return a mock async_session that hands out task and logs to successive
|
||||
.execute() calls. consolidate_task calls execute three times: select task,
|
||||
select logs (same context), then re-select task in the write-back block."""
|
||||
mock_session = AsyncMock()
|
||||
|
||||
task_result = MagicMock()
|
||||
task_result.scalars.return_value.first.return_value = task
|
||||
logs_result = MagicMock()
|
||||
logs_result.scalars.return_value.all.return_value = logs
|
||||
|
||||
mock_session.execute = AsyncMock(
|
||||
side_effect=[task_result, logs_result, task_result],
|
||||
)
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
return mock_session
|
||||
|
||||
|
||||
async def test_consolidate_task_writes_body_and_timestamp_and_reembeds():
|
||||
task = MagicMock()
|
||||
task.id = 42
|
||||
task.title = "Cert renewal"
|
||||
task.description = "renew LE before Nov 30"
|
||||
task.status = "in_progress"
|
||||
task.body = "old body"
|
||||
task.consolidated_at = None
|
||||
logs = [
|
||||
SimpleNamespace(
|
||||
content="tried certbot renew",
|
||||
created_at=datetime(2026, 5, 13, tzinfo=timezone.utc),
|
||||
),
|
||||
SimpleNamespace(
|
||||
content="switched to manual",
|
||||
created_at=datetime(2026, 5, 13, 1, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
fake_summary = (
|
||||
"Started with certbot renew; DNS split-horizon failed. "
|
||||
"Switched to manual flow."
|
||||
)
|
||||
|
||||
# Each consolidate_task call needs its own per-task lock state.
|
||||
# _locks is module-level defaultdict — patch it on the module to avoid
|
||||
# cross-test leakage of the held-lock state.
|
||||
from collections import defaultdict
|
||||
import asyncio as _asyncio
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.consolidation._locks",
|
||||
new=defaultdict(_asyncio.Lock),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.async_session",
|
||||
return_value=_make_mock_session(task, logs),
|
||||
), patch(
|
||||
"fabledassistant.services.llm.generate_completion",
|
||||
new=AsyncMock(return_value=fake_summary),
|
||||
) as mock_llm, patch(
|
||||
"fabledassistant.services.embeddings.upsert_note_embedding",
|
||||
new=AsyncMock(),
|
||||
) as mock_embed, patch(
|
||||
"fabledassistant.services.settings.get_setting",
|
||||
new=AsyncMock(return_value="gemma3:4b"),
|
||||
):
|
||||
from fabledassistant.services.consolidation import consolidate_task
|
||||
await consolidate_task(1, 42)
|
||||
|
||||
mock_llm.assert_awaited_once()
|
||||
mock_embed.assert_awaited_once()
|
||||
assert task.body == fake_summary
|
||||
assert task.consolidated_at is not None
|
||||
|
||||
|
||||
async def test_consolidate_task_llm_failure_leaves_body_untouched():
|
||||
task = MagicMock()
|
||||
task.id = 42
|
||||
task.title = "X"
|
||||
task.description = "y"
|
||||
task.status = "in_progress"
|
||||
task.body = "old body content"
|
||||
task.consolidated_at = None
|
||||
logs = [
|
||||
SimpleNamespace(
|
||||
content="a", created_at=datetime(2026, 5, 13, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
from collections import defaultdict
|
||||
import asyncio as _asyncio
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.consolidation._locks",
|
||||
new=defaultdict(_asyncio.Lock),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.async_session",
|
||||
return_value=_make_mock_session(task, logs),
|
||||
), patch(
|
||||
"fabledassistant.services.llm.generate_completion",
|
||||
new=AsyncMock(side_effect=RuntimeError("ollama down")),
|
||||
), patch(
|
||||
"fabledassistant.services.embeddings.upsert_note_embedding",
|
||||
new=AsyncMock(),
|
||||
) as mock_embed, patch(
|
||||
"fabledassistant.services.settings.get_setting",
|
||||
new=AsyncMock(return_value="gemma3:4b"),
|
||||
):
|
||||
from fabledassistant.services.consolidation import consolidate_task
|
||||
await consolidate_task(1, 42) # must not raise
|
||||
|
||||
assert task.body == "old body content"
|
||||
assert task.consolidated_at is None
|
||||
mock_embed.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_consolidate_task_skips_when_no_logs():
|
||||
task = MagicMock()
|
||||
task.id = 42
|
||||
task.title = "X"
|
||||
task.description = "y"
|
||||
task.status = "in_progress"
|
||||
task.body = "old"
|
||||
task.consolidated_at = None
|
||||
|
||||
from collections import defaultdict
|
||||
import asyncio as _asyncio
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.consolidation._locks",
|
||||
new=defaultdict(_asyncio.Lock),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.async_session",
|
||||
return_value=_make_mock_session(task, []),
|
||||
), patch(
|
||||
"fabledassistant.services.llm.generate_completion", new=AsyncMock(),
|
||||
) as mock_llm, patch(
|
||||
"fabledassistant.services.settings.get_setting",
|
||||
new=AsyncMock(return_value="gemma3:4b"),
|
||||
):
|
||||
from fabledassistant.services.consolidation import consolidate_task
|
||||
await consolidate_task(1, 42)
|
||||
|
||||
mock_llm.assert_not_awaited()
|
||||
assert task.body == "old"
|
||||
@@ -14,7 +14,7 @@ def _make_mock_session():
|
||||
|
||||
|
||||
def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
|
||||
caldav_uid="", color=""):
|
||||
caldav_uid="", color="", duration_minutes=60):
|
||||
e = MagicMock()
|
||||
e.id = id
|
||||
e.user_id = user_id
|
||||
@@ -23,7 +23,14 @@ def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
|
||||
e.caldav_uid = caldav_uid
|
||||
e.color = color
|
||||
e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc)
|
||||
e.end_dt = datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc)
|
||||
e.duration_minutes = duration_minutes
|
||||
# end_dt is derived; mirror the property's behavior on the mock so
|
||||
# service code that reads `event.end_dt` gets a sensible value.
|
||||
if duration_minutes is None:
|
||||
e.end_dt = None
|
||||
else:
|
||||
from datetime import timedelta
|
||||
e.end_dt = e.start_dt + timedelta(minutes=duration_minutes)
|
||||
e.all_day = False
|
||||
e.description = ""
|
||||
e.location = ""
|
||||
@@ -32,8 +39,9 @@ def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
|
||||
e.to_dict.return_value = {
|
||||
"id": id, "uid": uid, "title": title,
|
||||
"caldav_uid": caldav_uid, "color": color,
|
||||
"start_dt": datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc).isoformat(),
|
||||
"end_dt": datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc).isoformat(),
|
||||
"start_dt": e.start_dt.isoformat(),
|
||||
"end_dt": e.end_dt.isoformat() if e.end_dt else None,
|
||||
"duration_minutes": duration_minutes,
|
||||
}
|
||||
return e
|
||||
|
||||
@@ -124,6 +132,195 @@ async def test_update_event_fires_caldav_push():
|
||||
assert mock_task.called
|
||||
|
||||
|
||||
# ── Duration-model write-side guarantees (Fable #160) ─────────────────────────
|
||||
|
||||
|
||||
def test_normalize_duration_from_end_dt():
|
||||
"""end_dt sugar converts to a positive minute count anchored on start."""
|
||||
from fabledassistant.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 5, 1, 9, 30, tzinfo=timezone.utc)
|
||||
assert _normalize_duration(start_dt=start, end_dt=end, duration_minutes=None) == 90
|
||||
|
||||
|
||||
def test_normalize_duration_zero_is_valid_point_event():
|
||||
"""end_dt == start_dt → duration 0. The point-with-zero-duration case
|
||||
is rare but legal (e.g. an instant marker); the duration model treats
|
||||
it the same as duration None for display purposes."""
|
||||
from fabledassistant.services.events import _normalize_duration
|
||||
same = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
|
||||
assert _normalize_duration(start_dt=same, end_dt=same, duration_minutes=None) == 0
|
||||
|
||||
|
||||
def test_normalize_duration_rejects_end_before_start():
|
||||
"""The exact 2026-04-29 prod failure: end 32 days before start.
|
||||
The duration model makes this inexpressible at the schema level
|
||||
via a CHECK constraint, but write-path callers still get a
|
||||
helpful ValueError if they construct an inconsistent (start, end)
|
||||
pair via the end_dt sugar."""
|
||||
from fabledassistant.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
|
||||
with pytest.raises(ValueError, match="at or after start_dt"):
|
||||
_normalize_duration(
|
||||
start_dt=start, end_dt=end_before, duration_minutes=None,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_duration_rejects_negative_duration():
|
||||
"""Direct duration_minutes < 0 is rejected. Mirrors the DB CHECK
|
||||
constraint at the service boundary so callers get a clean error
|
||||
rather than a constraint violation from psycopg."""
|
||||
from fabledassistant.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
with pytest.raises(ValueError, match="must be >= 0"):
|
||||
_normalize_duration(start_dt=start, end_dt=None, duration_minutes=-15)
|
||||
|
||||
|
||||
def test_normalize_duration_rejects_inconsistent_end_and_duration():
|
||||
"""If a caller passes both end_dt AND duration_minutes that disagree,
|
||||
the inconsistency is surfaced rather than silently picking one."""
|
||||
from fabledassistant.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 5, 1, 13, 0, tzinfo=timezone.utc) # implies 60 min
|
||||
with pytest.raises(ValueError, match="implies 60 minutes"):
|
||||
_normalize_duration(
|
||||
start_dt=start, end_dt=end, duration_minutes=30,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_duration_none_for_open_ended():
|
||||
"""Both inputs None → None duration (open-ended event)."""
|
||||
from fabledassistant.services.events import _normalize_duration
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
assert _normalize_duration(
|
||||
start_dt=start, end_dt=None, duration_minutes=None,
|
||||
) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_rejects_end_before_start():
|
||||
"""Service-level rejection — same scenario as the prod bug, surfaced
|
||||
cleanly for tool / route callers via ValueError."""
|
||||
from fabledassistant.services.events import create_event
|
||||
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
|
||||
with pytest.raises(ValueError, match="at or after start_dt"):
|
||||
await create_event(
|
||||
user_id=1, title="Bad",
|
||||
start_dt=start, end_dt=end_before,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_preserves_duration_when_only_start_changes():
|
||||
"""Sliding semantics: when the user moves an event by changing only
|
||||
start_dt, the existing duration_minutes is preserved as-is. The new
|
||||
effective end_dt slides forward with the start. This is a behavioral
|
||||
upgrade vs. the old end_dt model, where moving start past the
|
||||
stored end made the event 'go backward in time'."""
|
||||
mock_event = _make_mock_event(duration_minutes=60) # start 10:00, end 11:00
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_event
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("fabledassistant.services.events.async_session") as mock_cls, \
|
||||
patch("fabledassistant.services.events.asyncio.create_task"):
|
||||
mock_cls.return_value = mock_session
|
||||
from fabledassistant.services.events import update_event
|
||||
# Move start to 12:00; effective end becomes 13:00 automatically.
|
||||
result = await update_event(
|
||||
user_id=1, event_id=1,
|
||||
start_dt=datetime(2026, 3, 25, 12, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
assert result is not None
|
||||
# duration_minutes was NOT touched; mock_event still has 60.
|
||||
assert mock_event.duration_minutes == 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_clearing_end_dt_clears_duration():
|
||||
"""Passing end_dt=None on update is the documented way to clear the
|
||||
end (turn a timed event into a point event). The service must
|
||||
translate that into duration_minutes=None, not leave the prior
|
||||
value in place."""
|
||||
mock_event = _make_mock_event(duration_minutes=60)
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_event
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("fabledassistant.services.events.async_session") as mock_cls, \
|
||||
patch("fabledassistant.services.events.asyncio.create_task"):
|
||||
mock_cls.return_value = mock_session
|
||||
from fabledassistant.services.events import update_event
|
||||
await update_event(user_id=1, event_id=1, end_dt=None)
|
||||
assert mock_event.duration_minutes is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_includes_point_event_in_window():
|
||||
"""A point event (duration_minutes=None) surfaces when its start
|
||||
is in the window. Replaces the prior 'corrupt end_dt' regression
|
||||
test — the duration model can't represent that state, but the
|
||||
same code path is exercised here for point events."""
|
||||
mock_event = _make_mock_event(duration_minutes=None)
|
||||
# Point event in the upcoming window
|
||||
mock_event.start_dt = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
|
||||
mock_event.end_dt = None
|
||||
mock_event.to_dict.return_value = {
|
||||
"id": 1, "title": "Point",
|
||||
"start_dt": mock_event.start_dt.isoformat(),
|
||||
"end_dt": None,
|
||||
"duration_minutes": None,
|
||||
}
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mock_event]
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("fabledassistant.services.events.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
from fabledassistant.services.events import list_events
|
||||
results = await list_events(
|
||||
user_id=1,
|
||||
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
|
||||
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_excludes_timed_event_that_already_ended():
|
||||
"""A timed event whose start + duration is before the window must
|
||||
NOT surface. Verifies the Python-side refinement actually works
|
||||
against the coarse SQL prefilter."""
|
||||
mock_event = _make_mock_event(duration_minutes=60)
|
||||
# Start 4/20 12:00, end 4/20 13:00; window is 4/29 → 5/27 — fully past.
|
||||
mock_event.start_dt = datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc)
|
||||
mock_event.end_dt = datetime(2026, 4, 20, 13, 0, tzinfo=timezone.utc)
|
||||
mock_event.to_dict.return_value = {
|
||||
"id": 1, "start_dt": mock_event.start_dt.isoformat(),
|
||||
"end_dt": mock_event.end_dt.isoformat(), "duration_minutes": 60,
|
||||
}
|
||||
mock_session = _make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [mock_event]
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("fabledassistant.services.events.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
from fabledassistant.services.events import list_events
|
||||
results = await list_events(
|
||||
user_id=1,
|
||||
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
|
||||
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
|
||||
)
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_calendar_always_available():
|
||||
"""Calendar tools must appear in get_tools_for_user even without CalDAV."""
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Tests for journal closeout extraction helpers."""
|
||||
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _msg(role: str, content: str, kind: str | None = None):
|
||||
"""Build a Message-like stand-in for the filter helpers."""
|
||||
metadata = {"kind": kind} if kind else None
|
||||
return SimpleNamespace(role=role, content=content, msg_metadata=metadata)
|
||||
|
||||
|
||||
def _fake_conv(conv_id: int = 1):
|
||||
return SimpleNamespace(id=conv_id)
|
||||
|
||||
|
||||
def _patch_db(messages, conv=None):
|
||||
"""Build a context manager that fakes async_session() and the two
|
||||
select() calls (conversation lookup + messages query)."""
|
||||
session = AsyncMock()
|
||||
session.execute = AsyncMock()
|
||||
conv_result = MagicMock()
|
||||
conv_result.scalar_one_or_none = MagicMock(return_value=conv)
|
||||
msg_result = MagicMock()
|
||||
scalars = MagicMock()
|
||||
scalars.all = MagicMock(return_value=list(messages))
|
||||
msg_result.scalars = MagicMock(return_value=scalars)
|
||||
session.execute.side_effect = [conv_result, msg_result]
|
||||
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__aenter__ = AsyncMock(return_value=session)
|
||||
session_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
return session_ctx
|
||||
|
||||
|
||||
def test_filter_excludes_daily_prep_messages():
|
||||
from fabledassistant.services.journal_closeout import _filter_messages
|
||||
|
||||
msgs = [
|
||||
_msg("assistant", "Good morning — here is today's plan…", kind="daily_prep"),
|
||||
_msg("user", "I want to focus on the auth refactor today."),
|
||||
_msg("assistant", "Got it. I'll keep tool calls quiet."),
|
||||
]
|
||||
kept = _filter_messages(msgs)
|
||||
contents = [m.content for m in kept]
|
||||
assert "Good morning — here is today's plan…" not in contents
|
||||
assert "I want to focus on the auth refactor today." in contents
|
||||
assert "Got it. I'll keep tool calls quiet." in contents
|
||||
|
||||
|
||||
def test_build_transcript_labels_roles_and_caps_content():
|
||||
from fabledassistant.services.journal_closeout import _build_transcript
|
||||
|
||||
msgs = [
|
||||
_msg("user", "hello"),
|
||||
_msg("assistant", "hi"),
|
||||
_msg("user", "x" * 600),
|
||||
]
|
||||
out = _build_transcript(msgs)
|
||||
lines = out.splitlines()
|
||||
assert lines[0] == "USER: hello"
|
||||
assert lines[1] == "ASSISTANT: hi"
|
||||
# Third line content truncated to 500 chars
|
||||
assert lines[2].startswith("USER: ")
|
||||
assert len(lines[2]) == len("USER: ") + 500
|
||||
|
||||
|
||||
def test_build_transcript_keeps_only_last_20_messages():
|
||||
from fabledassistant.services.journal_closeout import _build_transcript
|
||||
|
||||
msgs = [_msg("user", f"msg-{i}") for i in range(30)]
|
||||
out = _build_transcript(msgs)
|
||||
lines = out.splitlines()
|
||||
assert len(lines) == 20
|
||||
# Newest 20 means msg-10 through msg-29
|
||||
assert lines[0] == "USER: msg-10"
|
||||
assert lines[-1] == "USER: msg-29"
|
||||
|
||||
|
||||
def test_system_prompt_lists_structured_fields_to_exclude():
|
||||
"""The prompt must explicitly tell the LLM not to restate structured
|
||||
fields, so the freeform learned_summary stays a narrow lane."""
|
||||
from fabledassistant.services.journal_closeout import SYSTEM_PROMPT
|
||||
|
||||
text = SYSTEM_PROMPT.lower()
|
||||
for field in ("name", "job title", "industry", "expertise", "response style", "tone", "interests"):
|
||||
assert field in text, f"system prompt should mention '{field}'"
|
||||
assert "(nothing to note)" in SYSTEM_PROMPT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_for_user_happy_path_appends_bullets():
|
||||
from fabledassistant.services import journal_closeout
|
||||
|
||||
yesterday = datetime.date(2026, 5, 11)
|
||||
msgs = [
|
||||
_msg("assistant", "Good morning — here's today.", kind="daily_prep"),
|
||||
_msg("user", "Skip the news section — I never read it."),
|
||||
_msg("assistant", "Noted. Will drop it."),
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())),
|
||||
patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")),
|
||||
patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="- User skips news section")),
|
||||
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
||||
):
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=yesterday)
|
||||
|
||||
mock_append.assert_awaited_once_with(42, "- User skips news section")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_for_user_skips_when_no_conversation():
|
||||
from fabledassistant.services import journal_closeout
|
||||
|
||||
with (
|
||||
patch.object(journal_closeout, "async_session", return_value=_patch_db([], conv=None)),
|
||||
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
||||
patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm,
|
||||
):
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
||||
|
||||
mock_append.assert_not_awaited()
|
||||
mock_llm.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_for_user_skips_when_only_prep_message_after_filter():
|
||||
"""If only the daily_prep message exists, the in-Python filter pass
|
||||
leaves zero messages and we short-circuit before the LLM."""
|
||||
from fabledassistant.services import journal_closeout
|
||||
|
||||
msgs_post_sql = [
|
||||
_msg("assistant", "Daily prep block.", kind="daily_prep"),
|
||||
]
|
||||
with (
|
||||
patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs_post_sql, conv=_fake_conv())),
|
||||
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
||||
patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm,
|
||||
):
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
||||
|
||||
mock_append.assert_not_awaited()
|
||||
mock_llm.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_for_user_respects_nothing_to_note_sentinel():
|
||||
from fabledassistant.services import journal_closeout
|
||||
|
||||
msgs = [_msg("user", "hi"), _msg("assistant", "hi back")]
|
||||
with (
|
||||
patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())),
|
||||
patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")),
|
||||
patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="(nothing to note)")),
|
||||
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
||||
):
|
||||
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
||||
|
||||
mock_append.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_schedule_registers_closeout_when_enabled(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
fake_scheduler = MagicMock()
|
||||
fake_scheduler.get_job = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"prep_enabled": False, # isolate the closeout job
|
||||
"closeout_enabled": True,
|
||||
"day_rollover_hour": 4,
|
||||
}))
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
|
||||
await sched.update_user_schedule(user_id=7)
|
||||
|
||||
added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
|
||||
assert "journal_closeout_7" in added
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_schedule_does_not_register_closeout_when_disabled(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
fake_scheduler = MagicMock()
|
||||
fake_scheduler.get_job = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"prep_enabled": False,
|
||||
"closeout_enabled": False,
|
||||
"day_rollover_hour": 4,
|
||||
}))
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
|
||||
await sched.update_user_schedule(user_id=7)
|
||||
|
||||
added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
|
||||
assert "journal_closeout_7" not in added
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closeout_catchup_skips_when_already_have_entry(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"closeout_enabled": True,
|
||||
"day_rollover_hour": 0, # slot has always passed
|
||||
}))
|
||||
yesterday = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)).date()
|
||||
fake_profile = SimpleNamespace(observations_raw=[{"date": yesterday.isoformat(), "bullets": "..."}])
|
||||
|
||||
from fabledassistant.services import user_profile as up
|
||||
monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile))
|
||||
|
||||
run_mock = AsyncMock()
|
||||
from fabledassistant.services import journal_closeout as jc
|
||||
monkeypatch.setattr(jc, "run_for_user", run_mock)
|
||||
|
||||
await sched._closeout_catchup(user_id=7)
|
||||
run_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closeout_catchup_runs_when_no_entry_yet(monkeypatch):
|
||||
from fabledassistant.services import journal_scheduler as sched
|
||||
|
||||
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
||||
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
||||
"closeout_enabled": True,
|
||||
"day_rollover_hour": 0,
|
||||
}))
|
||||
fake_profile = SimpleNamespace(observations_raw=[])
|
||||
|
||||
from fabledassistant.services import user_profile as up
|
||||
monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile))
|
||||
|
||||
run_mock = AsyncMock()
|
||||
from fabledassistant.services import journal_closeout as jc
|
||||
monkeypatch.setattr(jc, "run_for_user", run_mock)
|
||||
|
||||
await sched._closeout_catchup(user_id=7)
|
||||
run_mock.assert_awaited_once()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Tests for the journal prep filtering helpers added in #159."""
|
||||
|
||||
import datetime
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── _task_to_prep_dict ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_task_to_prep_dict_marks_overdue_with_days_count():
|
||||
from fabledassistant.services.journal_prep import _task_to_prep_dict
|
||||
|
||||
today = datetime.date(2026, 4, 29)
|
||||
task = SimpleNamespace(
|
||||
id=2, title="Research Weston's ADHD Evaluation",
|
||||
status="todo", priority="high",
|
||||
due_date=datetime.date(2026, 2, 20),
|
||||
)
|
||||
d = _task_to_prep_dict(task, today)
|
||||
assert d["days_overdue"] == 68
|
||||
assert d["due_date"] == "2026-02-20"
|
||||
|
||||
|
||||
def test_task_to_prep_dict_due_today_no_overdue_marker():
|
||||
from fabledassistant.services.journal_prep import _task_to_prep_dict
|
||||
|
||||
today = datetime.date(2026, 4, 29)
|
||||
task = SimpleNamespace(
|
||||
id=10, title="Pick up dry cleaning",
|
||||
status="todo", priority="none",
|
||||
due_date=today,
|
||||
)
|
||||
d = _task_to_prep_dict(task, today)
|
||||
assert "days_overdue" not in d
|
||||
|
||||
|
||||
def test_task_to_prep_dict_handles_no_due_date():
|
||||
from fabledassistant.services.journal_prep import _task_to_prep_dict
|
||||
|
||||
task = SimpleNamespace(
|
||||
id=11, title="Long-running side project",
|
||||
status="in_progress", priority="medium",
|
||||
due_date=None,
|
||||
)
|
||||
d = _task_to_prep_dict(task, datetime.date(2026, 4, 29))
|
||||
assert d["due_date"] is None
|
||||
assert "days_overdue" not in d
|
||||
|
||||
|
||||
# ── _filter_proximate_events ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_filter_proximate_drops_far_future_recurring_event():
|
||||
"""The reported failure: a recurring Birthday event with start_dt
|
||||
2026-09-29 surfaced in every daily prep. The proximity filter drops it."""
|
||||
from fabledassistant.services.journal_prep import _filter_proximate_events
|
||||
|
||||
events = [
|
||||
{"id": 13, "title": "Birthday", "start_dt": "2026-09-29T00:00:00+00:00"},
|
||||
]
|
||||
kept = _filter_proximate_events(
|
||||
events,
|
||||
day_date=datetime.date(2026, 4, 29),
|
||||
user_tz=ZoneInfo("America/New_York"),
|
||||
)
|
||||
assert kept == []
|
||||
|
||||
|
||||
def test_filter_proximate_keeps_events_within_window():
|
||||
"""Events within ±7 days of the prep date stay."""
|
||||
from fabledassistant.services.journal_prep import _filter_proximate_events
|
||||
|
||||
events = [
|
||||
{"id": 1, "title": "Today", "start_dt": "2026-04-29T13:00:00+00:00"},
|
||||
{"id": 2, "title": "Tomorrow", "start_dt": "2026-04-30T12:00:00+00:00"},
|
||||
{"id": 3, "title": "Next week", "start_dt": "2026-05-05T15:00:00+00:00"},
|
||||
{"id": 4, "title": "Two weeks out", "start_dt": "2026-05-15T15:00:00+00:00"},
|
||||
]
|
||||
kept = _filter_proximate_events(
|
||||
events,
|
||||
day_date=datetime.date(2026, 4, 29),
|
||||
user_tz=ZoneInfo("America/New_York"),
|
||||
)
|
||||
kept_ids = [e["id"] for e in kept]
|
||||
assert 1 in kept_ids
|
||||
assert 2 in kept_ids
|
||||
assert 3 in kept_ids
|
||||
assert 4 not in kept_ids
|
||||
|
||||
|
||||
def test_filter_proximate_uses_local_date_not_utc():
|
||||
"""An event at 04:30 UTC on 2026-04-30 = 00:30 local on 2026-04-30 in
|
||||
NY (EDT, UTC-4). Local date is 2026-04-30, delta from 2026-04-29 = 1
|
||||
day. Must NOT cross-classify based on UTC date alone."""
|
||||
from fabledassistant.services.journal_prep import _filter_proximate_events
|
||||
|
||||
events = [
|
||||
{"id": 99, "title": "Late-night dentist", "start_dt": "2026-04-30T04:30:00+00:00"},
|
||||
]
|
||||
kept = _filter_proximate_events(
|
||||
events,
|
||||
day_date=datetime.date(2026, 4, 29),
|
||||
user_tz=ZoneInfo("America/New_York"),
|
||||
)
|
||||
assert len(kept) == 1
|
||||
|
||||
|
||||
def test_filter_proximate_keeps_unparseable_dates():
|
||||
"""Bad date strings are kept rather than silently suppressing real
|
||||
events on a parser bug."""
|
||||
from fabledassistant.services.journal_prep import _filter_proximate_events
|
||||
|
||||
events = [
|
||||
{"id": 50, "title": "Garbage", "start_dt": "not-a-date"},
|
||||
{"id": 51, "title": "Empty", "start_dt": ""},
|
||||
{"id": 52, "title": "Missing"},
|
||||
]
|
||||
kept = _filter_proximate_events(
|
||||
events,
|
||||
day_date=datetime.date(2026, 4, 29),
|
||||
user_tz=ZoneInfo("America/New_York"),
|
||||
)
|
||||
assert len(kept) == 3
|
||||
|
||||
|
||||
# ── _render_sections_for_prompt — overdue framing ────────────────────────────
|
||||
|
||||
|
||||
def test_render_overdue_includes_staleness_duration():
|
||||
"""The rendered prompt block must surface days_overdue so the LLM
|
||||
can frame stale tasks correctly."""
|
||||
from fabledassistant.services.journal_prep import _render_sections_for_prompt
|
||||
|
||||
sections = {
|
||||
"tasks_due_today": [],
|
||||
"tasks_upcoming": [],
|
||||
"tasks_overdue": [{
|
||||
"id": 2, "title": "Research Weston's ADHD Evaluation",
|
||||
"status": "todo", "priority": "high",
|
||||
"due_date": "2026-02-20", "days_overdue": 68,
|
||||
}],
|
||||
}
|
||||
rendered = _render_sections_for_prompt(sections)
|
||||
assert "OVERDUE TASKS" in rendered
|
||||
assert "68 days ago" in rendered
|
||||
assert "2026-02-20" in rendered
|
||||
# Crucially, NOT framed as due today.
|
||||
assert "DUE TODAY" not in rendered
|
||||
|
||||
|
||||
def test_render_due_today_no_due_date_repetition():
|
||||
"""Tasks in the DUE TODAY bucket don't need the (due 2026-04-29)
|
||||
parenthetical — the section header already says 'today'."""
|
||||
from fabledassistant.services.journal_prep import _render_sections_for_prompt
|
||||
|
||||
sections = {
|
||||
"tasks_due_today": [{
|
||||
"id": 1, "title": "Pick up dry cleaning",
|
||||
"status": "todo", "priority": "none",
|
||||
"due_date": "2026-04-29",
|
||||
}],
|
||||
"tasks_upcoming": [],
|
||||
"tasks_overdue": [],
|
||||
}
|
||||
rendered = _render_sections_for_prompt(sections)
|
||||
assert "TASKS DUE TODAY" in rendered
|
||||
assert "Pick up dry cleaning" in rendered
|
||||
# The line itself shouldn't repeat the due date.
|
||||
line = next(
|
||||
(l for l in rendered.splitlines() if "Pick up dry cleaning" in l),
|
||||
"",
|
||||
)
|
||||
assert "2026-04-29" not in line
|
||||
|
||||
|
||||
def test_render_three_buckets_in_correct_order():
|
||||
"""When all three buckets have content, the prompt sees them in
|
||||
DUE TODAY → UPCOMING → OVERDUE order so the LLM leads with today."""
|
||||
from fabledassistant.services.journal_prep import _render_sections_for_prompt
|
||||
|
||||
sections = {
|
||||
"tasks_due_today": [{"id": 1, "title": "Today task", "status": "todo", "priority": "none", "due_date": "2026-04-29"}],
|
||||
"tasks_upcoming": [{"id": 2, "title": "Upcoming task", "status": "todo", "priority": "none", "due_date": "2026-05-02"}],
|
||||
"tasks_overdue": [{"id": 3, "title": "Stale task", "status": "todo", "priority": "none", "due_date": "2026-02-20", "days_overdue": 68}],
|
||||
}
|
||||
rendered = _render_sections_for_prompt(sections)
|
||||
today_idx = rendered.index("TASKS DUE TODAY")
|
||||
upcoming_idx = rendered.index("UPCOMING TASKS")
|
||||
overdue_idx = rendered.index("OVERDUE TASKS")
|
||||
assert today_idx < upcoming_idx < overdue_idx
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for the status-terminal consolidation trigger in update_note."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
def _mock_session_for_update(mock_note):
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = mock_note
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
return mock_session
|
||||
|
||||
|
||||
async def test_update_note_to_done_triggers_consolidation():
|
||||
"""status: in_progress → done should fire maybe_consolidate(task_closed)."""
|
||||
mock_note = MagicMock()
|
||||
mock_note.id = 42
|
||||
mock_note.status = "in_progress" # pre-update state
|
||||
mock_note.body = ""
|
||||
mock_note.title = ""
|
||||
mock_note.tags = []
|
||||
mock_note.started_at = None
|
||||
mock_note.completed_at = None
|
||||
mock_note.recurrence_rule = None
|
||||
mock_note.project_id = None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.notes.async_session",
|
||||
return_value=_mock_session_for_update(mock_note),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.maybe_consolidate",
|
||||
new=AsyncMock(),
|
||||
) as mock_mc:
|
||||
from fabledassistant.services.notes import update_note
|
||||
await update_note(1, 42, status="done")
|
||||
|
||||
mock_mc.assert_awaited_once_with(1, 42, reason="task_closed")
|
||||
|
||||
|
||||
async def test_update_note_to_cancelled_triggers_consolidation():
|
||||
mock_note = MagicMock()
|
||||
mock_note.id = 42
|
||||
mock_note.status = "in_progress"
|
||||
mock_note.body = ""
|
||||
mock_note.title = ""
|
||||
mock_note.tags = []
|
||||
mock_note.started_at = None
|
||||
mock_note.completed_at = None
|
||||
mock_note.recurrence_rule = None
|
||||
mock_note.project_id = None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.notes.async_session",
|
||||
return_value=_mock_session_for_update(mock_note),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.maybe_consolidate",
|
||||
new=AsyncMock(),
|
||||
) as mock_mc:
|
||||
from fabledassistant.services.notes import update_note
|
||||
await update_note(1, 42, status="cancelled")
|
||||
|
||||
mock_mc.assert_awaited_once_with(1, 42, reason="task_closed")
|
||||
|
||||
|
||||
async def test_update_note_to_in_progress_does_not_trigger_consolidation():
|
||||
"""Non-terminal status changes don't fire the trigger."""
|
||||
mock_note = MagicMock()
|
||||
mock_note.id = 42
|
||||
mock_note.status = "todo"
|
||||
mock_note.body = ""
|
||||
mock_note.title = ""
|
||||
mock_note.tags = []
|
||||
mock_note.started_at = None
|
||||
mock_note.completed_at = None
|
||||
mock_note.recurrence_rule = None
|
||||
mock_note.project_id = None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.notes.async_session",
|
||||
return_value=_mock_session_for_update(mock_note),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.maybe_consolidate",
|
||||
new=AsyncMock(),
|
||||
) as mock_mc:
|
||||
from fabledassistant.services.notes import update_note
|
||||
await update_note(1, 42, status="in_progress")
|
||||
|
||||
mock_mc.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_update_note_already_done_does_not_retrigger():
|
||||
"""If the status was already 'done' and update_note(status='done') runs
|
||||
again, no fresh trigger fires — only transitions count."""
|
||||
mock_note = MagicMock()
|
||||
mock_note.id = 42
|
||||
mock_note.status = "done"
|
||||
mock_note.body = ""
|
||||
mock_note.title = ""
|
||||
mock_note.tags = []
|
||||
mock_note.started_at = None
|
||||
mock_note.completed_at = None
|
||||
mock_note.recurrence_rule = None
|
||||
mock_note.project_id = None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.notes.async_session",
|
||||
return_value=_mock_session_for_update(mock_note),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.maybe_consolidate",
|
||||
new=AsyncMock(),
|
||||
) as mock_mc:
|
||||
from fabledassistant.services.notes import update_note
|
||||
await update_note(1, 42, status="done")
|
||||
|
||||
mock_mc.assert_not_awaited()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for the description-field roundtrip through the notes service.
|
||||
|
||||
The description field is the user-stated goal/context on a task; distinct
|
||||
from `body` which (post-Task-as-Durable-Record) becomes the LLM-maintained
|
||||
consolidation summary.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
def _mock_session_for_update(mock_note):
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = mock_note
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
return mock_session
|
||||
|
||||
|
||||
async def test_update_note_persists_description():
|
||||
"""update_note(description=...) should assign to note.description.
|
||||
|
||||
update_note already accepts **fields and uses setattr, so this exercises
|
||||
that the new model column is reachable through the existing dynamic-fields
|
||||
path (no service-layer change required beyond the model column).
|
||||
"""
|
||||
mock_note = MagicMock()
|
||||
# Pre-existing state — None description, no recurrence, status untouched.
|
||||
mock_note.description = None
|
||||
mock_note.status = None
|
||||
mock_note.recurrence_rule = None
|
||||
mock_note.project_id = None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.notes.async_session",
|
||||
return_value=_mock_session_for_update(mock_note),
|
||||
):
|
||||
from fabledassistant.services.notes import update_note
|
||||
await update_note(1, 1, description="the goal text")
|
||||
|
||||
assert mock_note.description == "the goal text"
|
||||
|
||||
|
||||
async def test_create_note_forwards_description_to_model():
|
||||
"""create_note(description=...) should pass description into the Note
|
||||
constructor so it gets persisted alongside title/body/etc."""
|
||||
captured: dict = {}
|
||||
|
||||
class FakeNote:
|
||||
def __init__(self, **kw):
|
||||
captured.update(kw)
|
||||
self.id = 1
|
||||
self.project_id = kw.get("project_id")
|
||||
for k, v in kw.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.notes.async_session", return_value=mock_session
|
||||
), patch("fabledassistant.services.notes.Note", FakeNote):
|
||||
from fabledassistant.services.notes import create_note
|
||||
await create_note(
|
||||
user_id=1, title="renew cert", description="the goal text",
|
||||
)
|
||||
|
||||
assert captured.get("description") == "the goal text"
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for the record_moment server-side data-hygiene guards.
|
||||
|
||||
These guard against two failure modes observed in real journal usage:
|
||||
|
||||
1. The LLM emits ``task_titles`` referencing a task that's only in the
|
||||
prep context — not actually mentioned by the user. Without a check,
|
||||
every moment ends up linked to whatever's open in the user's queue.
|
||||
|
||||
2. The LLM emits generic placeholder ``place_names`` like ``"work"`` /
|
||||
``"home"`` instead of real place notes. These role-labels aren't
|
||||
places.
|
||||
|
||||
Both are exercised through the pure helpers; full-stack handler tests
|
||||
would require a session-bound DB fixture this suite doesn't have yet.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_content_keywords_drops_stopwords_and_short_tokens():
|
||||
from fabledassistant.services.tools.journal import _content_keywords
|
||||
|
||||
kw = _content_keywords(
|
||||
"I went to the store to pick up milk and bread for the kids."
|
||||
)
|
||||
# Stopwords (the, to, for, and, i) gone; short tokens (kids? 4 chars - kept)
|
||||
# filtered. Real content words kept.
|
||||
assert "store" in kw
|
||||
assert "milk" in kw
|
||||
assert "bread" in kw
|
||||
assert "kids" in kw
|
||||
assert "the" not in kw
|
||||
assert "to" not in kw
|
||||
assert "for" not in kw
|
||||
assert "i" not in kw
|
||||
|
||||
|
||||
def test_content_keywords_extracts_named_entities():
|
||||
"""Place names and project nouns survive tokenization. Short numeric
|
||||
fragments (e.g. '14') get filtered alongside other <3-char tokens —
|
||||
the surrounding alpha keywords carry the overlap weight in practice."""
|
||||
from fabledassistant.services.tools.journal import _content_keywords
|
||||
|
||||
kw = _content_keywords("Migration prep at Branch 14 Bedford.")
|
||||
assert "branch" in kw
|
||||
assert "bedford" in kw
|
||||
assert "migration" in kw
|
||||
|
||||
|
||||
def test_filter_placeholder_places_drops_generic_role_labels():
|
||||
from fabledassistant.services.tools.journal import _filter_placeholder_places
|
||||
|
||||
kept, dropped = _filter_placeholder_places(
|
||||
["work", "Famous Supply", "home", "Branch 14 Bedford", "the office"]
|
||||
)
|
||||
assert "Famous Supply" in kept
|
||||
assert "Branch 14 Bedford" in kept
|
||||
assert "work" in dropped
|
||||
assert "home" in dropped
|
||||
assert "the office" in dropped
|
||||
|
||||
|
||||
def test_filter_placeholder_places_is_case_insensitive():
|
||||
from fabledassistant.services.tools.journal import _filter_placeholder_places
|
||||
|
||||
kept, dropped = _filter_placeholder_places(["WORK", "Home", "OFFICE"])
|
||||
assert kept == []
|
||||
assert set(dropped) == {"WORK", "Home", "OFFICE"}
|
||||
|
||||
|
||||
def test_filter_placeholder_places_preserves_real_places_named_similarly():
|
||||
"""A user-defined place that happens to be ONE word but isn't a generic
|
||||
role-label (e.g. 'Akron') stays."""
|
||||
from fabledassistant.services.tools.journal import _filter_placeholder_places
|
||||
|
||||
kept, dropped = _filter_placeholder_places(["Akron", "Cleveland"])
|
||||
assert kept == ["Akron", "Cleveland"]
|
||||
assert dropped == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_overlap_drops_unrelated_task_link():
|
||||
"""The reported failure mode: model attached Weston's ADHD task to a
|
||||
Docker-swarm moment. With the keyword guard, the link gets dropped."""
|
||||
from fabledassistant.services.tools.journal import (
|
||||
_filter_task_ids_by_keyword_overlap,
|
||||
)
|
||||
|
||||
# Mock the DB lookup to return the offending task title for id=2.
|
||||
fake_session = AsyncMock()
|
||||
fake_session.execute = AsyncMock()
|
||||
fake_session.execute.return_value.all = lambda: [
|
||||
(2, "Research Weston's ADHD Evaluation"),
|
||||
]
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.journal.async_session",
|
||||
return_value=fake_session,
|
||||
):
|
||||
kept = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=1,
|
||||
content="Working on restaging Docker in the swarm; encountered network breakage on a Windows node.",
|
||||
task_ids=[2],
|
||||
)
|
||||
|
||||
assert kept == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_overlap_keeps_genuinely_referenced_task():
|
||||
"""When the moment content shares a meaningful keyword with the task
|
||||
title, the link is preserved."""
|
||||
from fabledassistant.services.tools.journal import (
|
||||
_filter_task_ids_by_keyword_overlap,
|
||||
)
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.execute = AsyncMock()
|
||||
fake_session.execute.return_value.all = lambda: [
|
||||
(5, "Restage Docker on Fam-dockerwin04"),
|
||||
]
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.journal.async_session",
|
||||
return_value=fake_session,
|
||||
):
|
||||
kept = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=1,
|
||||
content="Reinstalled Microsoft container support; Docker restage now works.",
|
||||
task_ids=[5],
|
||||
)
|
||||
|
||||
# 'docker' appears in both → keep
|
||||
assert kept == [5]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_overlap_handles_partial_relevance():
|
||||
"""Mixed ids: keep the related one, drop the unrelated one."""
|
||||
from fabledassistant.services.tools.journal import (
|
||||
_filter_task_ids_by_keyword_overlap,
|
||||
)
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.execute = AsyncMock()
|
||||
fake_session.execute.return_value.all = lambda: [
|
||||
(2, "Research Weston's ADHD Evaluation"),
|
||||
(5, "Restage Docker on Fam-dockerwin04"),
|
||||
]
|
||||
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
fake_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.journal.async_session",
|
||||
return_value=fake_session,
|
||||
):
|
||||
kept = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=1,
|
||||
content="Working on restaging Docker in the swarm.",
|
||||
task_ids=[2, 5],
|
||||
)
|
||||
|
||||
assert kept == [5]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_overlap_empty_task_ids_returns_empty():
|
||||
from fabledassistant.services.tools.journal import (
|
||||
_filter_task_ids_by_keyword_overlap,
|
||||
)
|
||||
|
||||
kept = await _filter_task_ids_by_keyword_overlap(
|
||||
user_id=1, content="anything", task_ids=[],
|
||||
)
|
||||
assert kept == []
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests for the tool-use fixes from the 2026-05-08 journal session."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _project(pid: int, title: str, description: str = "", auto_summary: str = ""):
|
||||
return SimpleNamespace(
|
||||
id=pid, title=title, description=description, auto_summary=auto_summary,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_projects_tool_returns_success_true():
|
||||
"""The dispatcher sets status from result['success']; without this key
|
||||
the call gets labeled 'error' even when data is returned."""
|
||||
from fabledassistant.services.tools import projects as projects_tool
|
||||
|
||||
fake_projects = [_project(5, "Famous-Supply Work topics", "AT&T fiber circuit")]
|
||||
|
||||
# `list_projects` is imported locally inside search_projects_tool, so we
|
||||
# patch the source module rather than the consumer.
|
||||
with patch("fabledassistant.services.projects.list_projects", new=AsyncMock(return_value=fake_projects)):
|
||||
result = await projects_tool.search_projects_tool(
|
||||
user_id=1, arguments={"query": "famous supply"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["type"] == "projects_list"
|
||||
assert len(result["data"]["projects"]) == 1
|
||||
|
||||
|
||||
def test_strip_type_nouns_removes_task_word():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("sebring secondary task") == ["sebring", "secondary"]
|
||||
|
||||
|
||||
def test_strip_type_nouns_removes_all_variants():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("project notes task") == []
|
||||
|
||||
|
||||
def test_strip_type_nouns_case_insensitive():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("Sebring Task NOTES") == ["Sebring"]
|
||||
|
||||
|
||||
def test_strip_type_nouns_preserves_real_content_words():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("circuit configuration") == ["circuit", "configuration"]
|
||||
|
||||
|
||||
def test_strip_type_nouns_handles_empty_string():
|
||||
from fabledassistant.services.notes import _strip_type_nouns
|
||||
assert _strip_type_nouns("") == []
|
||||
assert _strip_type_nouns(" ") == []
|
||||
|
||||
|
||||
def test_score_project_match_exact_title_returns_1():
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(5, "Famous-Supply Work topics")
|
||||
assert score_project_match("Famous-Supply Work topics", p) == 1.0
|
||||
|
||||
|
||||
def test_score_project_match_colloquial_substring_at_least_85():
|
||||
"""'famous supply' is a substring of normalized 'famous supply work topics'
|
||||
after stripping the hyphen. Substring match returns 0.85."""
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit")
|
||||
score = score_project_match("famous supply project", p)
|
||||
assert score >= 0.85, f"expected substring tier (>=0.85), got {score}"
|
||||
|
||||
|
||||
def test_score_project_match_query_in_summary_returns_70():
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(12, "Minstrel", auto_summary="self-hosted music server")
|
||||
assert score_project_match("music server", p) == 0.70
|
||||
|
||||
|
||||
def test_score_project_match_unrelated_returns_low():
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(12, "Minstrel", auto_summary="self-hosted music server")
|
||||
assert score_project_match("garden renovation", p) < 0.5
|
||||
|
||||
|
||||
def test_score_project_match_empty_query_returns_zero():
|
||||
from fabledassistant.services.tools._helpers import score_project_match
|
||||
p = _project(5, "Famous-Supply Work topics")
|
||||
assert score_project_match("", p) == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_projects_tool_ranks_substring_match_above_others():
|
||||
"""Substring hits (score 0.85) must rank above SequenceMatcher misses
|
||||
for unrelated projects."""
|
||||
from fabledassistant.services.tools import projects as projects_tool
|
||||
|
||||
fake_projects = [
|
||||
_project(12, "Minstrel", auto_summary="self-hosted music server"),
|
||||
_project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit"),
|
||||
_project(13, "ImageRepo", auto_summary="self-hosted Flask app"),
|
||||
]
|
||||
|
||||
with patch("fabledassistant.services.projects.list_projects", new=AsyncMock(return_value=fake_projects)):
|
||||
result = await projects_tool.search_projects_tool(
|
||||
user_id=1, arguments={"query": "famous supply project"},
|
||||
)
|
||||
|
||||
top = result["data"]["projects"][0]
|
||||
assert top["id"] == 5, f"expected Famous-Supply to rank first, got {top}"
|
||||
assert top["score"] >= 0.85
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_project_finds_colloquial_match(monkeypatch):
|
||||
"""resolve_project must surface 'Famous-Supply Work topics' when the
|
||||
user passes 'famous supply project' — substring match via the shared
|
||||
score helper, score 0.85 ≥ 0.55 threshold."""
|
||||
from fabledassistant.services.tools import _helpers
|
||||
|
||||
fake_projects = [
|
||||
_project(12, "Minstrel", auto_summary="self-hosted music server"),
|
||||
_project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit"),
|
||||
]
|
||||
|
||||
async def fake_get_project_by_title(uid, name):
|
||||
return None # force the scored path
|
||||
|
||||
async def fake_list_projects(uid):
|
||||
return fake_projects
|
||||
|
||||
monkeypatch.setattr(
|
||||
"fabledassistant.services.projects.get_project_by_title",
|
||||
fake_get_project_by_title,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"fabledassistant.services.projects.list_projects",
|
||||
fake_list_projects,
|
||||
)
|
||||
|
||||
result = await _helpers.resolve_project(user_id=1, project_name="famous supply project")
|
||||
assert result is not None
|
||||
assert result.id == 5
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Tests for the create_note / update_note LLM tools.
|
||||
|
||||
Verifies the new task-as-durable-record contract:
|
||||
- create_note drops `body` when a task is being created (status set).
|
||||
- create_note forwards `description` to the service.
|
||||
- update_note rejects `body` writes when the target is a task.
|
||||
- update_note accepts `description` updates on tasks.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
async def test_create_note_tool_ignores_body_when_creating_task():
|
||||
"""Status present → is_task=True → body must be dropped before the
|
||||
create_note service call; description must be forwarded."""
|
||||
from fabledassistant.services.tools import notes as notes_tool
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_create_note(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(
|
||||
id=1,
|
||||
title=kwargs["title"],
|
||||
status=kwargs.get("status"),
|
||||
priority=kwargs.get("priority"),
|
||||
due_date=None,
|
||||
project_id=None,
|
||||
milestone_id=None,
|
||||
parent_id=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.notes.create_note", new=fake_create_note,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.check_duplicate",
|
||||
new=AsyncMock(return_value=None),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.suggest_tags",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.schedule_embedding",
|
||||
):
|
||||
await notes_tool.create_note_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "renew cert",
|
||||
"status": "todo",
|
||||
"description": "the goal text",
|
||||
"body": "this should be dropped on tasks",
|
||||
},
|
||||
)
|
||||
|
||||
assert captured.get("description") == "the goal text"
|
||||
# Body dropped because is_task=True. consolidation owns the body field.
|
||||
assert not captured.get("body")
|
||||
|
||||
|
||||
async def test_create_note_tool_preserves_body_for_knowledge_notes():
|
||||
"""No status → is_task=False → body is preserved as today."""
|
||||
from fabledassistant.services.tools import notes as notes_tool
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_create_note(**kwargs):
|
||||
captured.update(kwargs)
|
||||
# Knowledge-note return path reads note.project_id; SimpleNamespace
|
||||
# needs the attribute even when it's None.
|
||||
return SimpleNamespace(
|
||||
id=1, title=kwargs["title"], status=None, project_id=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.notes.create_note", new=fake_create_note,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.check_duplicate",
|
||||
new=AsyncMock(return_value=None),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.suggest_tags",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.schedule_embedding",
|
||||
):
|
||||
await notes_tool.create_note_tool(
|
||||
user_id=1,
|
||||
arguments={
|
||||
"title": "runbook",
|
||||
"body": "preserved markdown content",
|
||||
},
|
||||
)
|
||||
|
||||
assert captured.get("body") == "preserved markdown content"
|
||||
|
||||
|
||||
async def test_update_note_tool_rejects_body_on_tasks():
|
||||
"""When the resolved note has is_task=True, providing body must error."""
|
||||
from fabledassistant.services.tools import notes as notes_tool
|
||||
|
||||
# SimpleNamespace can't fake a @property; build is_task as a real attr.
|
||||
fake_task = SimpleNamespace(
|
||||
id=42, title="t", status="in_progress",
|
||||
body="", tags=[], project_id=None, milestone_id=None,
|
||||
)
|
||||
fake_task.is_task = True
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.notes.get_note_by_title",
|
||||
new=AsyncMock(return_value=fake_task),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.update_note", new=AsyncMock(),
|
||||
) as mock_update:
|
||||
result = await notes_tool.update_note_tool(
|
||||
user_id=1,
|
||||
arguments={"query": "t", "body": "trying to overwrite"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
err = result.get("error", "").lower()
|
||||
assert "body" in err or "log_work" in err
|
||||
mock_update.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_update_note_tool_accepts_description_on_tasks():
|
||||
"""description updates flow through to the service even on tasks."""
|
||||
from fabledassistant.services.tools import notes as notes_tool
|
||||
|
||||
fake_task = SimpleNamespace(
|
||||
id=42, title="t", status="in_progress",
|
||||
body="", tags=[], project_id=None, milestone_id=None,
|
||||
description=None,
|
||||
)
|
||||
fake_task.is_task = True
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_update_note(uid, nid, **kwargs):
|
||||
captured.update(kwargs)
|
||||
# Apply the description so the post-update inspection makes sense.
|
||||
for k, v in kwargs.items():
|
||||
setattr(fake_task, k, v)
|
||||
return fake_task
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.notes.get_note_by_title",
|
||||
new=AsyncMock(return_value=fake_task),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.update_note",
|
||||
new=fake_update_note,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.suggest_tags",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.schedule_embedding",
|
||||
):
|
||||
result = await notes_tool.update_note_tool(
|
||||
user_id=1,
|
||||
arguments={"query": "t", "description": "updated goal"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured.get("description") == "updated goal"
|
||||
|
||||
|
||||
async def test_update_note_tool_accepts_body_on_knowledge_notes():
|
||||
"""Body writes are still allowed on non-task notes."""
|
||||
from fabledassistant.services.tools import notes as notes_tool
|
||||
|
||||
fake_note = SimpleNamespace(
|
||||
id=10, title="n", status=None,
|
||||
body="old", tags=[], project_id=None, milestone_id=None,
|
||||
)
|
||||
fake_note.is_task = False
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_update_note(uid, nid, **kwargs):
|
||||
captured.update(kwargs)
|
||||
for k, v in kwargs.items():
|
||||
setattr(fake_note, k, v)
|
||||
return fake_note
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.notes.get_note_by_title",
|
||||
new=AsyncMock(return_value=fake_note),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.update_note",
|
||||
new=fake_update_note,
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.suggest_tags",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"fabledassistant.services.tools.notes.schedule_embedding",
|
||||
):
|
||||
result = await notes_tool.update_note_tool(
|
||||
user_id=1,
|
||||
arguments={"query": "n", "body": "new body content"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured.get("body") == "new body content"
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for the task tools — log_work in particular wires into the
|
||||
consolidation pipeline after every successful log."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
async def test_log_work_tool_invokes_maybe_consolidate():
|
||||
"""log_work must call maybe_consolidate(user_id, task_id, reason='log_added')
|
||||
after a successful task_logs.create_log."""
|
||||
from fabledassistant.services.tools import tasks as tasks_tool
|
||||
|
||||
fake_task = SimpleNamespace(id=42, title="X", status="in_progress")
|
||||
fake_log = SimpleNamespace(
|
||||
to_dict=lambda: {"id": 1, "content": "did stuff"},
|
||||
)
|
||||
|
||||
# get_note_by_title is imported at the top of tools/tasks.py — patch the
|
||||
# consumer module's bound symbol, not the source.
|
||||
with patch(
|
||||
"fabledassistant.services.tools.tasks.get_note_by_title",
|
||||
new=AsyncMock(return_value=fake_task),
|
||||
), patch(
|
||||
"fabledassistant.services.task_logs.create_log",
|
||||
new=AsyncMock(return_value=fake_log),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.maybe_consolidate",
|
||||
new=AsyncMock(),
|
||||
) as mock_mc:
|
||||
result = await tasks_tool.log_work_tool(
|
||||
user_id=1, arguments={"task": "X", "content": "did stuff"},
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
mock_mc.assert_awaited_once_with(1, 42, reason="log_added")
|
||||
|
||||
|
||||
async def test_log_work_tool_does_not_trigger_on_create_log_failure():
|
||||
"""If create_log raises ValueError, maybe_consolidate must not be called."""
|
||||
from fabledassistant.services.tools import tasks as tasks_tool
|
||||
|
||||
fake_task = SimpleNamespace(id=42, title="X", status="in_progress")
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.tools.tasks.get_note_by_title",
|
||||
new=AsyncMock(return_value=fake_task),
|
||||
), patch(
|
||||
"fabledassistant.services.task_logs.create_log",
|
||||
new=AsyncMock(side_effect=ValueError("bad input")),
|
||||
), patch(
|
||||
"fabledassistant.services.consolidation.maybe_consolidate",
|
||||
new=AsyncMock(),
|
||||
) as mock_mc:
|
||||
result = await tasks_tool.log_work_tool(
|
||||
user_id=1, arguments={"task": "X", "content": "did stuff"},
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
mock_mc.assert_not_awaited()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Tests for manual pin / unpin on note versions."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
def _mock_session_for_version(mock_version):
|
||||
mock_session = AsyncMock()
|
||||
result = MagicMock()
|
||||
result.scalars.return_value.first.return_value = mock_version
|
||||
mock_session.execute = AsyncMock(return_value=result)
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
return mock_session
|
||||
|
||||
|
||||
async def test_pin_version_sets_manual_kind_and_label():
|
||||
mock_version = MagicMock()
|
||||
mock_version.pin_kind = None
|
||||
mock_version.pin_label = None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.version_pinning.async_session",
|
||||
return_value=_mock_session_for_version(mock_version),
|
||||
):
|
||||
from fabledassistant.services.version_pinning import pin_version
|
||||
result = await pin_version(
|
||||
user_id=1, note_id=42, version_id=7, label="the runbook circa Q2",
|
||||
)
|
||||
|
||||
assert result is mock_version
|
||||
assert mock_version.pin_kind == "manual"
|
||||
assert mock_version.pin_label == "the runbook circa Q2"
|
||||
|
||||
|
||||
async def test_pin_version_accepts_null_label():
|
||||
"""label is optional — pinning with no label is allowed."""
|
||||
mock_version = MagicMock()
|
||||
mock_version.pin_kind = None
|
||||
mock_version.pin_label = None
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.version_pinning.async_session",
|
||||
return_value=_mock_session_for_version(mock_version),
|
||||
):
|
||||
from fabledassistant.services.version_pinning import pin_version
|
||||
await pin_version(user_id=1, note_id=42, version_id=7, label=None)
|
||||
|
||||
assert mock_version.pin_kind == "manual"
|
||||
assert mock_version.pin_label is None
|
||||
|
||||
|
||||
async def test_pin_version_promotes_auto_to_manual_with_label_update():
|
||||
"""Pinning an already-auto-pinned version promotes it and updates label."""
|
||||
mock_version = MagicMock()
|
||||
mock_version.pin_kind = "auto"
|
||||
mock_version.pin_label = "stable 2026-04-01 → 2026-04-05"
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.version_pinning.async_session",
|
||||
return_value=_mock_session_for_version(mock_version),
|
||||
):
|
||||
from fabledassistant.services.version_pinning import pin_version
|
||||
await pin_version(
|
||||
user_id=1, note_id=42, version_id=7, label="post-network-refresh",
|
||||
)
|
||||
|
||||
assert mock_version.pin_kind == "manual"
|
||||
assert mock_version.pin_label == "post-network-refresh"
|
||||
|
||||
|
||||
async def test_pin_version_rejects_overlong_label():
|
||||
"""Labels are capped at 500 chars to keep the UI sane."""
|
||||
mock_version = MagicMock()
|
||||
mock_version.pin_kind = None
|
||||
|
||||
# The cap is checked before any DB access, so the session shouldn't
|
||||
# even be entered. We still patch it to avoid accidental DB lookups
|
||||
# if the implementation order changes.
|
||||
with patch(
|
||||
"fabledassistant.services.version_pinning.async_session",
|
||||
return_value=_mock_session_for_version(mock_version),
|
||||
):
|
||||
from fabledassistant.services.version_pinning import pin_version
|
||||
try:
|
||||
await pin_version(
|
||||
user_id=1, note_id=42, version_id=7, label="x" * 501,
|
||||
)
|
||||
assert False, "expected ValueError"
|
||||
except ValueError as e:
|
||||
assert "500" in str(e) or "long" in str(e).lower()
|
||||
|
||||
|
||||
async def test_pin_version_returns_none_when_not_found():
|
||||
mock_session = AsyncMock()
|
||||
result = MagicMock()
|
||||
result.scalars.return_value.first.return_value = None
|
||||
mock_session.execute = AsyncMock(return_value=result)
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.version_pinning.async_session",
|
||||
return_value=mock_session,
|
||||
):
|
||||
from fabledassistant.services.version_pinning import pin_version
|
||||
out = await pin_version(user_id=1, note_id=42, version_id=99, label=None)
|
||||
|
||||
assert out is None
|
||||
|
||||
|
||||
async def test_unpin_version_clears_kind_and_label():
|
||||
mock_version = MagicMock()
|
||||
mock_version.pin_kind = "manual"
|
||||
mock_version.pin_label = "previous label"
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.version_pinning.async_session",
|
||||
return_value=_mock_session_for_version(mock_version),
|
||||
):
|
||||
from fabledassistant.services.version_pinning import unpin_version
|
||||
result = await unpin_version(user_id=1, note_id=42, version_id=7)
|
||||
|
||||
assert result is mock_version
|
||||
assert mock_version.pin_kind is None
|
||||
assert mock_version.pin_label is None
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for the prune logic — both rolling (pin_kind IS NULL) and the
|
||||
auto-pin bucket (pin_kind='auto').
|
||||
|
||||
Design: docs/superpowers/specs/2026-05-13-note-version-pinning-design.md
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
async def test_create_version_prune_sql_filters_to_unpinned():
|
||||
"""The DELETE statement issued by create_version's prune step must
|
||||
include `pin_kind IS NULL` in the inner SELECT so pinned versions
|
||||
aren't counted toward MAX_VERSIONS and can't be pruned by it."""
|
||||
mock_session = AsyncMock()
|
||||
select_result = MagicMock()
|
||||
# No prior version → skips the throttle/dedupe early-return paths and
|
||||
# proceeds straight to insert + prune.
|
||||
select_result.scalars.return_value.first.return_value = None
|
||||
mock_session.add = MagicMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
captured_sql: list[str] = []
|
||||
|
||||
async def execute_capture(stmt, *args, **kwargs):
|
||||
captured_sql.append(str(stmt))
|
||||
if len(captured_sql) == 1:
|
||||
return select_result
|
||||
return MagicMock()
|
||||
|
||||
mock_session.execute = AsyncMock(side_effect=execute_capture)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.note_versions.async_session",
|
||||
return_value=mock_session,
|
||||
):
|
||||
from fabledassistant.services.note_versions import create_version
|
||||
await create_version(
|
||||
user_id=1, note_id=42, body="content", title="t", tags=["a"],
|
||||
)
|
||||
|
||||
assert any("pin_kind IS NULL" in s for s in captured_sql), (
|
||||
f"Expected prune SQL to filter pin_kind IS NULL; got: {captured_sql!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_prune_auto_pins_filters_to_auto_kind():
|
||||
"""prune_auto_pins must filter to pin_kind='auto' so manual pins and
|
||||
rolling rows aren't touched, and must bind MAX_AUTO_PINS as the OFFSET."""
|
||||
mock_session = AsyncMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
captured_sql: list[str] = []
|
||||
captured_params: list[dict] = []
|
||||
|
||||
async def execute_capture(stmt, *args, **kwargs):
|
||||
captured_sql.append(str(stmt))
|
||||
try:
|
||||
captured_params.append(dict(stmt.compile().params))
|
||||
except Exception:
|
||||
captured_params.append({})
|
||||
return MagicMock()
|
||||
|
||||
mock_session.execute = AsyncMock(side_effect=execute_capture)
|
||||
|
||||
with patch(
|
||||
"fabledassistant.services.version_pinning.async_session",
|
||||
return_value=mock_session,
|
||||
):
|
||||
from fabledassistant.services.version_pinning import (
|
||||
prune_auto_pins, MAX_AUTO_PINS,
|
||||
)
|
||||
await prune_auto_pins(user_id=1, note_id=42)
|
||||
|
||||
assert any("pin_kind = 'auto'" in s for s in captured_sql), (
|
||||
f"expected auto-bucket filter in SQL; got: {captured_sql!r}"
|
||||
)
|
||||
assert any(
|
||||
p.get("max_auto_pins") == MAX_AUTO_PINS for p in captured_params
|
||||
), f"bound params: {captured_params!r}"
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Tests for the auto-pin scan algorithm.
|
||||
|
||||
Tests the per-note promotion logic directly via a pure helper so the
|
||||
algorithm can be driven without standing up a real DB.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def _v(version_id: int, days_ago: int, pin_kind=None):
|
||||
"""Build a SimpleNamespace mimicking a NoteVersion row."""
|
||||
return SimpleNamespace(
|
||||
id=version_id,
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=days_ago),
|
||||
pin_kind=pin_kind,
|
||||
pin_label=None,
|
||||
)
|
||||
|
||||
|
||||
def test_promote_stable_versions_pins_versions_with_2_day_gap():
|
||||
"""Versions followed by another version >= 2 days later get promoted."""
|
||||
from fabledassistant.services.version_pinning import (
|
||||
_promote_stable_versions_for_note,
|
||||
)
|
||||
# 3 versions: v1 (10 days ago), v2 (5 days ago), v3 (1 day ago)
|
||||
# Gaps: v1→v2 = 5d, v2→v3 = 4d. Both ≥ 2d → both pinned.
|
||||
# v3 (latest) has gap to now = 1d → NOT pinned (< 2 days).
|
||||
versions = [_v(1, 10), _v(2, 5), _v(3, 1)]
|
||||
pinned = _promote_stable_versions_for_note(versions)
|
||||
assert {v.id for v in pinned} == {1, 2}
|
||||
assert versions[0].pin_kind == "auto"
|
||||
assert versions[1].pin_kind == "auto"
|
||||
assert versions[2].pin_kind is None
|
||||
|
||||
|
||||
def test_promote_stable_versions_pins_latest_if_old_enough():
|
||||
"""If the latest version is >= 2 days old with no successor, pin it."""
|
||||
from fabledassistant.services.version_pinning import (
|
||||
_promote_stable_versions_for_note,
|
||||
)
|
||||
versions = [_v(1, 10), _v(2, 3)] # latest is 3 days old
|
||||
pinned = _promote_stable_versions_for_note(versions)
|
||||
assert {v.id for v in pinned} == {1, 2}
|
||||
|
||||
|
||||
def test_promote_stable_versions_skips_already_pinned():
|
||||
from fabledassistant.services.version_pinning import (
|
||||
_promote_stable_versions_for_note,
|
||||
)
|
||||
versions = [_v(1, 10, pin_kind="manual"), _v(2, 5)]
|
||||
pinned = _promote_stable_versions_for_note(versions)
|
||||
# v1 already manual — not re-pinned. v2 has 5-day gap to v1, then
|
||||
# latest-with-no-successor gap of 5d to now → pinned auto.
|
||||
assert {v.id for v in pinned} == {2}
|
||||
assert versions[0].pin_kind == "manual"
|
||||
assert versions[1].pin_kind == "auto"
|
||||
|
||||
|
||||
def test_promote_stable_versions_skips_active_editing():
|
||||
"""Versions a few hours apart with no >2-day gaps → no pins."""
|
||||
base = datetime.now(timezone.utc)
|
||||
v1 = SimpleNamespace(
|
||||
id=1, created_at=base - timedelta(hours=10),
|
||||
pin_kind=None, pin_label=None,
|
||||
)
|
||||
v2 = SimpleNamespace(
|
||||
id=2, created_at=base - timedelta(hours=2),
|
||||
pin_kind=None, pin_label=None,
|
||||
)
|
||||
from fabledassistant.services.version_pinning import (
|
||||
_promote_stable_versions_for_note,
|
||||
)
|
||||
pinned = _promote_stable_versions_for_note([v1, v2])
|
||||
assert pinned == []
|
||||
|
||||
|
||||
def test_promote_stable_versions_writes_descriptive_label():
|
||||
"""The auto-generated label references stability."""
|
||||
from fabledassistant.services.version_pinning import (
|
||||
_promote_stable_versions_for_note,
|
||||
)
|
||||
versions = [_v(1, 10), _v(2, 5), _v(3, 1)]
|
||||
_promote_stable_versions_for_note(versions)
|
||||
assert versions[0].pin_label is not None
|
||||
assert "stable" in versions[0].pin_label.lower()
|
||||
|
||||
|
||||
def test_promote_stable_versions_naive_datetime_is_treated_as_utc():
|
||||
"""created_at may come in as a naive datetime from some code paths;
|
||||
the algorithm coerces it to UTC rather than crashing on subtraction."""
|
||||
naive_old = datetime.utcnow() - timedelta(days=10)
|
||||
naive_recent = datetime.utcnow() - timedelta(days=1)
|
||||
versions = [
|
||||
SimpleNamespace(id=1, created_at=naive_old, pin_kind=None, pin_label=None),
|
||||
SimpleNamespace(id=2, created_at=naive_recent, pin_kind=None, pin_label=None),
|
||||
]
|
||||
from fabledassistant.services.version_pinning import (
|
||||
_promote_stable_versions_for_note,
|
||||
)
|
||||
pinned = _promote_stable_versions_for_note(versions)
|
||||
# v1 → v2 gap is 9 days → v1 pinned. v2 → now is 1 day → not pinned.
|
||||
assert {v.id for v in pinned} == {1}
|
||||
|
||||
|
||||
async def test_scan_user_for_auto_pins_iterates_all_notes(monkeypatch):
|
||||
"""scan_user_for_auto_pins iterates every note for the user and calls
|
||||
the per-note flow on each, summing returned counts."""
|
||||
from fabledassistant.services import version_pinning
|
||||
|
||||
note_ids = [10, 20, 30]
|
||||
seen: list[int] = []
|
||||
|
||||
async def fake_list_note_ids(uid):
|
||||
return note_ids
|
||||
|
||||
async def fake_one_note(uid, nid):
|
||||
seen.append(nid)
|
||||
# Pretend each note pinned one version.
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(
|
||||
version_pinning,
|
||||
"_list_user_note_ids_with_versions",
|
||||
fake_list_note_ids,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
version_pinning, "_scan_one_note", fake_one_note,
|
||||
)
|
||||
|
||||
total = await version_pinning.scan_user_for_auto_pins(user_id=1)
|
||||
|
||||
assert seen == note_ids
|
||||
assert total == 3
|
||||
|
||||
|
||||
async def test_scan_user_for_auto_pins_swallows_per_note_errors(monkeypatch):
|
||||
"""One bad note doesn't stop the scan; the error is logged and the
|
||||
others continue."""
|
||||
from fabledassistant.services import version_pinning
|
||||
|
||||
async def fake_list_note_ids(uid):
|
||||
return [10, 20, 30]
|
||||
|
||||
async def fake_one_note(uid, nid):
|
||||
if nid == 20:
|
||||
raise RuntimeError("simulated DB error")
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(
|
||||
version_pinning,
|
||||
"_list_user_note_ids_with_versions",
|
||||
fake_list_note_ids,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
version_pinning, "_scan_one_note", fake_one_note,
|
||||
)
|
||||
|
||||
total = await version_pinning.scan_user_for_auto_pins(user_id=1)
|
||||
|
||||
# 10 and 30 each contributed 1; 20 raised and contributed 0.
|
||||
assert total == 2
|
||||
Reference in New Issue
Block a user