Compare commits

...

58 Commits

Author SHA1 Message Date
bvandeusen 2414437061 Merge pull request 'feat: closeout + tool-use fixes + task-as-record + version pinning' (#50) from dev into main 2026-05-13 23:27:33 +00:00
bvandeusen e6f2ee2b94 feat(frontend): pin/unpin/auto-pin UI in HistoryPanel
Version list rows now render a kind-aware badge: filled circle for
manual pins (with the label inline), half-filled circle for auto-pinned
versions. The right pane gains a control row above the diff:

- Unpinned: 'Pin version' button → label input → Save creates a manual
  pin with that label.
- Manual: 'Edit label' + 'Unpin' buttons.
- Auto: 'Pin permanently' (promotes auto → manual with editable label).

Local state is patched from the API response so the UI updates without
reloading the panel.
2026-05-13 14:00:57 -04:00
bvandeusen 59dee3a19f feat(frontend): NoteVersion pin fields + pin/unpin client helpers 2026-05-13 13:59:47 -04:00
bvandeusen ce41f2a3ee feat(versions): include pin_kind/pin_label in backup export+restore
Both export paths emit pin_kind and pin_label per note_version row.
Restore reads them via .get() so backups predating the schema still
import cleanly (defaults to None → rolling).
2026-05-13 13:59:20 -04:00
bvandeusen b1226d4e16 feat(versions): daily 03:00 UTC auto-pin scan scheduler
BackgroundScheduler with a single CronTrigger fires scan_all_users_for
_auto_pins via asyncio.run_coroutine_threadsafe (mirrors the journal-
scheduler pattern). Wired into app startup/shutdown alongside the other
schedulers.
2026-05-13 13:58:34 -04:00
bvandeusen 37c704e875 feat(versions): auto-pin scan promotes stable versions
_promote_stable_versions_for_note is the pure-function core: walks
versions chronologically and pins any with a >= AUTO_PIN_STABILITY_DAYS
(2-day) gap to the next version (or to now, for the latest). Auto-
generated label describes the stability window.

_scan_one_note loads versions for one note, runs the promotion, commits
mutations to the attached rows, then calls prune_auto_pins to cap the
auto bucket. scan_user_for_auto_pins fans out across the user's notes;
scan_all_users_for_auto_pins is the top-level entrypoint for the cron.
Per-note and per-user errors are caught and logged.
2026-05-13 13:57:56 -04:00
bvandeusen bb6249e00e feat(versions): prune_auto_pins FIFO-trims auto-pinned bucket
Auto-pinned versions live in their own bucket with MAX_AUTO_PINS=25 cap.
The scan job calls this after each note's promotions complete; the
oldest auto-pinned rows are dropped past the cap. Manual pins and
rolling rows are untouched.
2026-05-13 13:57:09 -04:00
bvandeusen 9c0308dfee feat(versions): POST/DELETE /api/notes/:id/versions/:vid/pin 2026-05-13 13:56:42 -04:00
bvandeusen 925a53e0f7 feat(versions): pin_version and unpin_version services
pin_version sets pin_kind='manual' and pin_label on the target row.
Accepts already-pinned rows (promotes auto→manual, updates label).
Labels are capped at PIN_LABEL_MAX_LEN=500 chars; longer values raise
ValueError before any DB access.

unpin_version clears both fields, downgrading the row to rolling. Does
NOT delete — if the row is past the rolling FIFO depth, the next
autosave's prune will drop it.
2026-05-13 13:56:21 -04:00
bvandeusen b65d736869 feat(versions): rolling-cap prune ignores pinned versions
The DELETE inside create_version now filters pin_kind IS NULL so pinned
rows (auto or manual) aren't counted toward MAX_VERSIONS=50 and aren't
candidates for deletion. Pinned versions live indefinitely regardless
of how heavy rolling autosave traffic gets on the same note.
2026-05-13 13:55:44 -04:00
bvandeusen 17211c6e82 feat(schema): add note_version.pin_kind and pin_label
Spec: docs/superpowers/specs/2026-05-13-note-version-pinning-design.md

- pin_kind: NULL=rolling, 'auto'=stability-scan, 'manual'=user-declared.
- pin_label: NULL for rolling; auto-generated for 'auto'; user-supplied
  string for 'manual' (may be NULL).

No backfill — every existing row stays rolling. The daily auto-pin scan
will catch up on the first run after deploy.
2026-05-13 13:55:18 -04:00
bvandeusen 90aa1f2fdb fix(tests): preserves_body test \$Note stub needs project_id
The knowledge-note return path in create_note_tool reads note.project_id;
the SimpleNamespace fake didn't define it, so the tool crashed with
AttributeError instead of returning. The task-branch test already
included project_id; mirror that here.
2026-05-13 12:30:09 -04:00
bvandeusen b519a1c140 feat(frontend): auto-consolidate tasks toggle in General settings
New Tasks section in the General tab with a single checkbox controlling
whether the consolidation pipeline fires automatically. Persists to the
auto_consolidate_tasks user setting (string 'true'/'false'). Manual
'Re-consolidate' in the task editor bypasses the gate.
2026-05-13 12:23:06 -04:00
bvandeusen 257b306a27 feat(frontend): gate body editor when task body is auto-maintained
When consolidated_at is set on a task, the editor:
- shows a banner above the body indicating the body is auto-summarized
- hides the Write tab; locks the body view to read-only preview
- exposes a Re-consolidate button that calls POST /api/tasks/:id/consolidate
  and refreshes the body from the response

Pre-consolidation behavior is unchanged — the Write tab and TiptapEditor
remain available.
2026-05-13 12:21:51 -04:00
bvandeusen 8b0878f227 feat(frontend): description field in task editor + Goal block in viewer
Note type gains description and consolidated_at fields. TaskEditorView
adds a Goal textarea above the body editor (wired through dirty/save/
autosave paths). TaskViewerView renders Goal as a subordinate block
above the body, plus a subtle 'Auto-summarized from work logs' banner
when consolidated_at is set.

Also adds a consolidateTask client function for the upcoming
re-consolidate button (Task 11).
2026-05-13 12:20:42 -04:00
bvandeusen 9191ab5b27 feat(tasks): POST /api/tasks/:id/consolidate + separate body/description fields
New endpoint manually triggers a consolidation pass for a single task.
Bypasses the auto_consolidate_tasks setting since the user is asking
explicitly. Returns the task with the freshly-written body and
consolidated_at timestamp.

Also un-aliases description and body in the create/update task routes
(was: description folded into body as legacy fallback). With separate
fields under the task-as-durable-record design, both flow through as
distinct kwargs to create_note / update_note.
2026-05-13 12:16:43 -04:00
bvandeusen fd25d2e436 docs(tools): clarify log_work feeds summary; create_note runbook guidance
log_work description now mentions that logs feed the task's auto-summary,
nudging the LLM toward specific log content (commands, decisions, failures)
rather than vague entries.

create_note description gains a runbook-shape clause: code blocks, numbered
procedures, and explicit 'save this as a note/runbook' signals should
spawn standalone notes. Task-specific work-in-progress routes to log_work
instead.
2026-05-13 12:15:36 -04:00
bvandeusen 103db883ad feat(tools): tasks accept description; reject body writes via tools
create_note tool:
- New 'description' parameter accepted and forwarded to the service.
- When status is set (creating a task), 'body' is dropped before the
  service call. Task bodies are owned by the consolidation pipeline.

update_note tool:
- New 'description' parameter; routed through update_fields.
- When the resolved target has is_task=True and 'body' is in the
  arguments, the call errors with a message nudging toward log_work or
  description. Knowledge notes are unaffected.

HTTP routes (POST/PATCH/PUT /api/notes) accept body freely — the
restriction is only at the LLM tool layer.
2026-05-13 12:15:09 -04:00
bvandeusen 5fa203019a feat(consolidation): trigger from log_work and status terminal transitions
log_work tool now invokes maybe_consolidate(reason='log_added') after a
successful create_log. The gate inside the consolidation service handles
threshold + setting checks.

update_note service snapshots old_status before mutation and fires
maybe_consolidate(reason='task_closed') when the status transitions into
'done' or 'cancelled'. Re-saving an already-terminal status doesn't
retrigger — only transitions count.
2026-05-13 12:13:31 -04:00
bvandeusen bda6e6c80f feat(consolidation): full consolidate_task with background model
consolidate_task reads the task title, description (read-only context),
and chronological work logs; builds a prompt via _build_consolidation_prompt;
calls generate_completion with the user's background_model setting; on a
non-empty result, writes back to Note.body, stamps consolidated_at, and
re-runs the embedding pipeline.

Errors are caught and logged. LLM failures leave body untouched so the
next trigger retries cleanly. Per-task asyncio lock prevents simultaneous
passes for the same task.
2026-05-13 12:12:37 -04:00
bvandeusen 5419330633 feat(consolidation): debounced gate for task body consolidation
New services/consolidation.py module with maybe_consolidate() — the
debounced trigger gate. Two reasons:

- log_added: gated by DEFAULT_LOG_THRESHOLD (3) counted since the task's
  consolidated_at timestamp.
- task_closed: bypasses the count gate; fires whenever status flips to
  done/cancelled.

Both reasons gated by the auto_consolidate_tasks user setting (default
on). Per-task asyncio.Lock prevents two simultaneous passes for the same
task. consolidate_task is a stub here — full implementation in the next
commit.
2026-05-13 12:11:41 -04:00
bvandeusen 362ead7f0d feat(notes): accept and return description field through service and routes
create_note service accepts a new description kwarg and forwards it to the
Note constructor. PUT/PATCH/POST routes include description in the field
whitelist. update_note already passed **fields through setattr, so the new
column is reachable without touching that signature.
2026-05-13 12:11:03 -04:00
bvandeusen 8a3bba4eb8 feat(schema): add note.description and note.consolidated_at
Spec: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md

- description: user-stated goal / initial context for tasks (NULL for
  knowledge notes).
- consolidated_at: timestamp of the most recent auto-summary pass (NULL
  until first consolidation).
- Migration 0044 backfills description from body for existing rows where
  status IS NOT NULL (i.e. tasks). Body left in place; first consolidation
  pass will overwrite it.
2026-05-13 12:09:45 -04:00
bvandeusen 76dc75a03b Merge pull request 'feat: journal closeout for profile observations + LLM tool-use fixes' (#49) from dev into main 2026-05-13 01:31:19 +00:00
bvandeusen a551f52682 fix(tools): score_project_match strips 'project' filler + uses title for SequenceMatcher
CI surfaced three issues:
- 'famous supply project' didn't substring-match 'Famous-Supply Work topics'
  because the trailing filler word 'project' blocked the substring tier.
  Strip {project, projects} from the query before the substring check.
- SequenceMatcher fallback against `combined` (title + description +
  summary) diluted ratios to ~0.5 for plausible matches. Use title
  directly; the 0.70 tier already handles description/summary mentions.
- Test patches used patch.object on a consumer module where
  list_projects is imported locally — patch the source module instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:30:37 -04:00
bvandeusen 6de855e226 feat(llm): search-first heuristic in chat tool_lines static block
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:44:00 -04:00
bvandeusen c6357e52d9 feat(journal): search-first heuristic for existing work in calibration prompt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:43:42 -04:00
bvandeusen 5d40f2113f feat(tools): record_moment requires task_titles from prior search_notes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:43:21 -04:00
bvandeusen 9a96fdb3c0 refactor(tools): resolve_project uses shared score_project_match helper
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:43:05 -04:00
bvandeusen 460959f0d4 refactor(tools): search_projects uses shared score_project_match helper
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:42:34 -04:00
bvandeusen 0dbbb98cf5 feat(tools): score_project_match helper with substring-first tiering
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:42:01 -04:00
bvandeusen 4b7ca1b17e feat(tools): search_notes description nudges away from type-nouns in query
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:41:28 -04:00
bvandeusen 65a3689aaa fix(notes): list_notes strips type-nouns from q before ILIKE filter
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:41:15 -04:00
bvandeusen 42c11dedae feat(notes): _strip_type_nouns helper for search query sanitization
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:40:54 -04:00
bvandeusen 0fbb1fbd92 fix(tools): search_projects returns success:True so result isn't mislabeled error
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:40:27 -04:00
bvandeusen bb650ba563 feat(profile): Settings panel with recent journal observations
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:44:51 -04:00
bvandeusen c663532fd4 feat(profile): Settings toggle for nightly journal closeout
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:43:59 -04:00
bvandeusen 090b7d83dd feat(frontend): API client for profile observations + closeout flag
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:43:27 -04:00
bvandeusen 4e9eead3ab feat(profile): GET /api/profile/observations returns recent raw entries
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:36:42 -04:00
bvandeusen fc6ebf81eb feat(journal): closeout catch-up on startup when slot already passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:36:29 -04:00
bvandeusen b88d5ee6b3 feat(journal): register per-user closeout job at day_rollover_hour
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:35:57 -04:00
bvandeusen 020bd6614b test(journal): cover closeout skip paths (no conv / prep-only / sentinel)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:34:58 -04:00
bvandeusen 4403026797 feat(journal): run_for_user orchestrates closeout extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:34:42 -04:00
bvandeusen 552943d6c0 feat(journal): closeout system prompt with structured-field guard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:33:57 -04:00
bvandeusen 2576be9e49 feat(journal): _build_transcript caps content and message window
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:33:27 -04:00
bvandeusen e17fc088b2 feat(journal): journal_closeout._filter_messages excludes daily_prep
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:33:03 -04:00
bvandeusen c5b0344240 feat(journal): add closeout_enabled to default config
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:32:40 -04:00
bvandeusen c8765959ea Merge pull request 'Release v26.04.29.4 — recurrence UI + journal weather wiring fixes' (#48) from dev into main 2026-04-30 02:08:19 +00:00
bvandeusen c33cab7020 fix(journal): wire weather refresh on config save; drop orphaned cache rows
Two related gaps in the journal weather panel:

1. Saving locations via PUT /journal/config didn't trigger a weather
   fetch, so newly-entered sites had no cache row (or a stale one) until
   the user manually clicked the panel's refresh button. The panel
   rendered "two sites with empty values" against pre-existing cache
   rows that no longer matched what the user had configured.

2. get_cached_weather_rows returned every WeatherCache row for the user
   regardless of whether the location was still in journal_config.
   Briefing-era rows survived migration 0040 (which only deleted the
   briefing_config setting, not the cache table) and showed up as
   ghost tabs in the UI.

Changes:
- get_cached_weather_rows accepts an optional valid_keys filter; rows
  whose location_key is not in the set are excluded.
- routes/journal.py:
  - put_config kicks off a background refresh_location_cache for any
    saved location with valid lat/lon.
  - GET /weather and POST /weather/refresh both pass valid_keys derived
    from the current config so orphaned rows don't surface.
- services/journal_prep.py filters the weather section to currently-
  configured locations as well; uses a lazy import of get_journal_config
  to avoid a cycle (journal_scheduler imports journal_prep).

153 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:37:23 -04:00
bvandeusen 36cd08c236 feat(events): expose recurrence presets in EventSlideOver
Adds a "Repeat" select (None / Daily / Weekly / Monthly / Yearly) that
reads/writes the existing Event.recurrence RRULE. CalDAV-imported rules
with extra parts (e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR) surface as a disabled
"Custom" option with the raw rule shown read-only — visible but
preserved unless the user explicitly picks a preset to replace it.

EventUpdatePayload.recurrence is now string | null so we can clear via
PATCH; backend service already treats null as "clear" (recurrence is in
the nullable set in update_event).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:19:49 -04:00
bvandeusen f85b92a885 Release v26.04.29.3 — Event-store data integrity + EventSlideOver redesign 2026-04-29 20:04:43 +00:00
bvandeusen 84640a0dc4 fix(events-tools): refuse ambiguous query, require event_id to disambiguate (#161)
Root cause of the 2026-04-29 dentist-appointment incident: the model
called update_event(query="Appointment") when two events had
"Appointment" in their titles. find_events_by_query returned both,
upcoming-first ordered by start_dt — matches[0] was id=2 (a stale
pre-existing event with garbage end_dt), not id=15 (the one the user
just created via the journal flow). update_event_tool silently took
matches[0] and mutated the wrong event.

Fix: a new resolver helper `_resolve_event_for_action` funnels both
update_event_tool and delete_event_tool through one disambiguation
path. Lookup precedence:
  - `event_id` → exact get_event lookup, no query at all
  - `query` matching exactly one event → proceed
  - `query` matching zero → return success=False, "no event found"
  - `query` matching 2+ events → return success=False with a
    `candidates` array of {id, title, start_dt, location} so the
    model can pick one and call again with `event_id`

The candidates list is capped at 8 to keep the model's context tight.
The error message names the count and the next-step ("pass event_id
or refine the query") so the model can self-correct in one turn.

For delete_event, the disambiguation is even more important — the
silent-matches[0] path would have deleted the wrong event outright
rather than just mutating it. The tool description leans into that:
"Deleting the wrong event is a costly user error; never guess."

Tool surface change: `query` and `event_id` are now both optional;
the tool errors clearly when neither is supplied. The model already
knows id values from prior tool results (returned in `data.id`),
which is the natural feeder for the disambiguation flow.

5 new tests in test_calendar_tool_tz.py cover:
- ambiguous query → success=False with candidate list, no mutation
- event_id supplied → bypasses query lookup entirely
- non-existent event_id → clear "no event found" error
- neither identifier → "query or event_id required" error
- same disambiguation enforced for delete_event_tool

46 calendar/events tests pass; ruff clean.

Closes Fable #161.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:29:13 -04:00
bvandeusen 4c58603009 feat(events): replace end_dt column with duration_minutes (#160)
Structural fix for the "end before start" bug class observed on prod
2026-04-29. Bad data became inexpressible at the schema level instead
of getting trapped in defensive read-path filters.

The hotfix that landed earlier today (94b169f) is reverted by the
preceding revert commit; this commit supersedes it cleanly with a
proper data-model change.

## Schema (migration 0043)

- Add `duration_minutes INTEGER NULLABLE` column on `events`.
- CHECK constraint: ``duration_minutes IS NULL OR duration_minutes >= 0``.
- Backfill from existing `end_dt`:
    - end_dt valid (end > start) → duration_minutes = total minutes
    - end_dt == start → duration_minutes = 0 (zero-duration point)
    - end_dt NULL or end_dt < start → duration_minutes = NULL
      (the corrupt prod row collapses cleanly to a point event)
- Drop the `end_dt` column. The wire format is preserved — `to_dict()`
  emits `end_dt` as a derived `start_dt + duration_minutes`. Existing
  API consumers (Flutter app, web frontend, CalDAV sync) keep
  receiving the same response shape; they just no longer have a way
  to PUT a stored `end_dt` that disagrees with `start_dt`.

## Service layer

- `Event.end_dt` becomes a `@property`. Setting it would require a
  setter we deliberately don't define — writes always go through
  `duration_minutes`.
- `_normalize_duration` is the single source-of-truth for input
  reduction. Accepts (start, end_dt, duration_minutes), returns the
  canonical `duration_minutes`, raises `ValueError` for negative
  durations, end-before-start, or end/duration disagreement.
- `create_event` and `update_event` accept either `end_dt` or
  `duration_minutes` for ergonomic compat; both convert via
  `_normalize_duration`. Update validates the post-update state when
  the patch includes either.
- `list_events` filter is simpler now: a coarse SQL prefilter
  (`start_dt <= date_to`) plus Python-side refinement using the
  derived `end_dt`. Avoids Postgres-specific interval arithmetic in
  the WHERE clause; refinement runs over a per-user result set so
  there's no scan-cost concern at personal scale.
- Recurring-event expansion uses `event.duration_minutes` directly
  instead of computing `end - start`. No more negative-timedelta
  hazard.

## CalDAV sync (incoming + outgoing)

- `caldav_sync.py` (pull) and `calendar_sync.py` (Radicale upsert)
  both convert iCal `DTEND` → `duration_minutes` on the way in.
  Outbound iCal still emits `DTEND` as `start_dt + duration_minutes`
  via the model's derived property. iCal interop is unchanged.

## Behavioral upgrade for `update_event`

Pure end_dt model: moving start past the existing end_dt would either
silently corrupt or hard-reject. Duration model: the duration is
preserved by default, so moving start slides the effective end
forward — which is what users mean when they "move" an event.
Explicit clear is still possible via `end_dt=None`.

## Tests

`tests/test_events_service.py`:
- 6 new `_normalize_duration` unit tests (sugar conversion, zero
  duration valid as point event, end-before-start rejected, negative
  duration rejected, inconsistent end+duration rejected, none → None)
- New behavioral test: `update_event` preserves duration when only
  start_dt changes (sliding semantics)
- New: clearing `end_dt=None` on update collapses to point event
- New: list_events surfaces a point event in the upcoming window
- New: list_events excludes a timed event whose effective end has
  already passed
- Existing mock-event helper updated to use `duration_minutes`
  instead of stored `end_dt`.

44 event-related tests pass; ruff clean.

## Out of scope (separate task)

Fable #161 — `find_events_by_query` returning multiple matches and
silently picking matches[0]. The exact root cause of how event id=2
got mutated in the first place; orthogonal to the storage model.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:19:44 -04:00
bvandeusen b7e7073425 Revert "fix(events): tolerate corrupt end_dt + reject end<=start at write time"
This reverts commit 94b169f31c.
2026-04-29 14:18:01 -04:00
bvandeusen 94b169f31c fix(events): tolerate corrupt end_dt + reject end<=start at write time
A prod event surfaced today with `start_dt=2026-05-01T12:00Z` and
`end_dt=2026-03-30T12:00Z` — end was 32 days BEFORE start, almost
certainly from an earlier tool-call mishap (Fable #161). The
list_events filter trusted the bogus end_dt and excluded the event
from every read path that hit the upcoming window, even though
start_dt was correctly in range. The event stayed visible in the
calendar grid (different range) but vanished from "Upcoming",
search, briefings, and journal prep events list.

This is the hotfix half of the response. The structural follow-up is
Fable #160 — replace end_dt with a duration column so invalid state
becomes inexpressible.

## A. Filter robustness in list_events

Treat `end_dt <= start_dt` as if no end_dt exists. The filter now
splits into two branches:
- valid duration: end_dt IS NOT NULL AND end_dt > start_dt AND
  end_dt >= date_from
- no/invalid duration: (end_dt IS NULL OR end_dt <= start_dt) AND
  start_dt >= date_from

Same change applied to the recurring-event expansion's `duration`
calculation, which was producing negative timedeltas for corrupted
rows and computing nonsensical occurrence end times.

## B. Write-side validation in create/update

`create_event` and `update_event` now raise ValueError when the
resulting state would have end_dt <= start_dt. Update validates
against the *post-update* state, not just the field being changed —
so pushing start_dt past an existing end_dt also fails loudly. Bad
data shouldn't be persistable from any write path.

Surfaced cleanly:
- Calendar tool wrappers (create_event_tool / update_event_tool)
  catch ValueError and return `{success: false, error: ...}`, which
  the model can read and self-correct.
- Route handlers (POST /api/events, PATCH /api/events/<id>) catch
  and return HTTP 400 with the validator's message instead of
  letting it bubble to a 500.

4 new tests in test_events_service.py:
- create rejects end before start
- create rejects equal start/end (zero duration)
- update validates the post-update state (start pushed past existing end)
- list_events surfaces an event whose end_dt is before its start_dt

34 event-related tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 13:48:28 -04:00
bvandeusen 2db23cec7a refactor(events): turn EventSlideOver into a centered modal with auto-save
User feedback: the right-edge slide-over panel pinned its action buttons
to a thin floor band where the destructive ghost-style Delete button was
functionally invisible against the dark surface. Save / Cancel / Delete
all sat in the same floor strip, isolated from the form content.

This refactor changes the surface and the commit model.

## Centered modal, not slide-over
Backdrop dim covers the whole viewport; the panel sits centered with a
12px corner radius and a soft shadow. The form scrolls internally when
content overflows the viewport (max-height: calc(100vh - 2.5rem)).
File kept as `EventSlideOver.vue` to avoid touching the three consumers
(CalendarView, HomeView, ToolCallCard).

## Action buttons removed; close = save
- Save button: gone. Auto-save fires when the user closes via X, Esc,
  backdrop click, or pressing Enter inside a text field.
- Cancel button: gone. Esc / X / backdrop click already cover dismiss;
  a labeled "Cancel" was redundant.
- Delete button: moved to the header as a Trash2 icon (edit mode only).
  Click → header swaps to inline confirm "Delete this event? [Yes,
  delete] [No]" — same two-step flow, just relocated. Esc during the
  confirm cancels back to edit mode rather than closing the modal,
  giving the user a clear way out of the destructive prompt.

## Validity-aware close
All exit paths funnel through `attemptClose`:
  - Form valid → save (POST or PATCH), then close.
  - Form invalid in EDIT mode → discard the in-memory change and
    close, with a toast naming the missing field
    ("Title required — change discarded"). Keeps the user from
    silently corrupting an event.
  - Form invalid in CREATE mode → close silently. Nothing was
    committed; calling that out adds noise.

Emit signature unchanged (close / created / updated / deleted), so the
three consumers continue to work without edits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 13:14:17 -04:00
bvandeusen b81c4aa600 Release v26.04.29.2 — Anti-placeholder rule on create_event 2026-04-29 16:10:53 +00:00
bvandeusen 35fab6cbf7 fix(calendar-tool): forbid placeholder events in create_event description
Reproducer (2026-04-29 dentist appointment): user said "this Friday,
I have an appointment" with no other details. The model immediately
called create_event with title="Appointment", description="User
mentioned an appointment this Friday but hasn't provided details
yet.", all_day=true. THEN it asked the user for time/location in
its reply. When the user came back with "8am at my dentist for
permanent crown fitting", the model called update_event — but never
updated the title, leaving the placeholder "Appointment" in the
calendar permanently.

The bug isn't about the tool surface, it's that the model created
an event before it had real content. The system prompt had no rule
against this, so the model hedged: "log a placeholder, ask for
details, then update". That pattern pollutes the calendar with
garbage titles and forces immediate update_event calls.

create_event tool description now includes an explicit anti-pattern:
record a moment, ask for the missing pieces, and only call create_event
once you have actual title + time + location. Stand-in titles like
"Appointment" / "Meeting" / "Event" with "details TBD" descriptions
are explicitly named as the failure mode.

Pure prompt change. 18 tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 10:54:39 -04:00
55 changed files with 4390 additions and 341 deletions
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -184,7 +184,7 @@ wheels = [
[[package]]
name = "fable-mcp"
version = "0.2.6"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
+39 -1
View File
@@ -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`)
+275 -154
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { X } from "lucide-vue-next";
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";
@@ -37,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);
@@ -115,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 {
@@ -130,6 +161,7 @@ function resetForm() {
location.value = "";
color.value = "";
projectId.value = null;
recurrence.value = "";
_lastDurationMin = 60;
}
deleteConfirm.value = false;
@@ -177,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))
@@ -214,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 = {
@@ -228,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 {
@@ -256,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"><X :size="16" /></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 }]"
@@ -281,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="#5B4A8A" />
<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>
@@ -356,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: 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: 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);
@@ -441,19 +578,35 @@ 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-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 {
background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30);
@@ -482,65 +635,14 @@ 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;
}
/* Save (in slide-over): Moss action-primary */
.btn-primary {
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.2rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
/* Cancel: Bronze action-secondary */
.btn-secondary {
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-secondary:hover { background: var(--color-action-secondary-hover); }
/* Delete (entry-point): Oxblood action-destructive ghost */
.btn-danger-ghost {
margin-left: auto;
background: none;
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-danger-ghost:hover { background: var(--color-action-destructive); color: #fff; }
/* Confirm-delete: Oxblood filled */
/* Confirm-delete buttons (only shown during the inline confirm flow) */
.btn-danger {
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
@@ -548,9 +650,28 @@ async function doDelete() {
.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>
+238 -3
View File
@@ -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>
@@ -268,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>
+2
View File
@@ -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;
+2
View File
@@ -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;
}
+106 -1
View File
@@ -4,7 +4,7 @@ import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile, type JournalConfig } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, listProfileObservations, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile, type JournalConfig, type ProfileObservationEntry } from "@/api/client";
import { usePushStore } from "@/stores/push";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
@@ -19,6 +19,21 @@ const defaultModel = ref("");
const userTimezone = ref("");
const savingTimezone = ref(false);
const timezoneSaved = ref(false);
const autoConsolidateTasks = ref(true);
const savingAutoConsolidate = ref(false);
async function saveAutoConsolidate() {
savingAutoConsolidate.value = true;
try {
await apiPut('/api/settings', {
auto_consolidate_tasks: autoConsolidateTasks.value ? "true" : "false",
});
} catch {
toastStore.show('Failed to save setting', 'error');
} finally {
savingAutoConsolidate.value = false;
}
}
function detectTimezone() {
userTimezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -549,6 +564,26 @@ const profileSaving = ref(false)
const profileSaved = ref(false)
const consolidating = ref(false)
const clearingObs = ref(false)
const observations = ref<ProfileObservationEntry[]>([])
const observationsExpanded = ref(false)
const observationsLoading = ref(false)
const observationsLoaded = ref(false)
async function toggleObservations() {
observationsExpanded.value = !observationsExpanded.value
if (observationsExpanded.value && !observationsLoaded.value) {
observationsLoading.value = true
try {
const res = await listProfileObservations()
observations.value = res.observations
observationsLoaded.value = true
} catch {
toastStore.show('Failed to load observations', 'error')
} finally {
observationsLoading.value = false
}
}
}
async function loadProfile() {
try { profile.value = await getProfile() } catch { /* non-critical */ }
@@ -582,6 +617,17 @@ function toggleProfileWorkDay(day: string) {
profile.value.work_schedule = { ...profile.value.work_schedule, days }
}
async function onToggleCloseout(enabled: boolean) {
journalConfig.value.closeout_enabled = enabled
try {
await saveJournalConfig(journalConfig.value)
toastStore.show(enabled ? 'Nightly closeout enabled' : 'Nightly closeout disabled')
} catch {
journalConfig.value.closeout_enabled = !enabled // revert UI on failure
toastStore.show('Failed to update closeout setting', 'error')
}
}
async function runConsolidate() {
consolidating.value = true
try {
@@ -600,6 +646,7 @@ const journalConfig = ref<JournalConfig>({
prep_hour: 5,
prep_minute: 0,
day_rollover_hour: 4,
closeout_enabled: true,
locations: { home: { label: 'Home', address: '' }, work: { label: 'Work', address: '' } },
temp_unit: 'C',
})
@@ -620,6 +667,7 @@ async function loadJournalConfig() {
prep_hour: cfg.prep_hour ?? 5,
prep_minute: cfg.prep_minute ?? 0,
day_rollover_hour: cfg.day_rollover_hour ?? 4,
closeout_enabled: cfg.closeout_enabled ?? true,
morning_end_hour: cfg.morning_end_hour,
midday_end_hour: cfg.midday_end_hour,
locations: {
@@ -689,6 +737,8 @@ async function clearObservations() {
profile.value.learned_summary = ''
profile.value.observations_count = 0
profile.value.observations_updated_at = null
observations.value = []
observationsLoaded.value = false
toastStore.show('Learned data cleared')
} catch { toastStore.show('Failed to clear observations', 'error') }
finally { clearingObs.value = false }
@@ -719,6 +769,8 @@ onMounted(async () => {
defaultModel.value = allSettings.default_model ?? "";
backgroundModel.value = allSettings.background_model ?? "";
userTimezone.value = allSettings.user_timezone ?? "";
// Default true if unset; explicit "false" disables auto-consolidation.
autoConsolidateTasks.value = (allSettings.auto_consolidate_tasks ?? "true") !== "false";
chatRetentionDays.value = allSettings.chat_retention_days !== undefined
? Number(allSettings.chat_retention_days)
: 90;
@@ -1475,6 +1527,29 @@ function formatUserDate(iso: string): string {
</div>
</section>
<!-- Tasks -->
<section class="settings-section full-width">
<h2>Tasks</h2>
<p class="section-desc">
Task bodies are auto-summarized from accumulated work logs. The summary runs every few logs, plus on task close.
</p>
<div class="field">
<label class="checkbox-label">
<input
type="checkbox"
v-model="autoConsolidateTasks"
:disabled="savingAutoConsolidate"
@change="saveAutoConsolidate"
/>
Auto-consolidate task bodies
</label>
<p class="field-hint">
When off, the task body is only refreshed when you click "Re-consolidate"
on a task. Existing summaries remain in place.
</p>
</div>
</section>
<!-- Timezone -->
<section class="settings-section full-width">
<h2>Timezone</h2>
@@ -1901,8 +1976,38 @@ function formatUserDate(iso: string): string {
The assistant observes patterns from your journal and chat conversations and builds a summary over time. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you.
<span v-if="profile.observations_count > 0"> {{ profile.observations_count }} raw observation{{ profile.observations_count !== 1 ? 's' : '' }} stored.</span>
</p>
<label class="toggle-row" style="display:flex;align-items:center;gap:0.6rem;margin:0.5rem 0 0.75rem">
<input
type="checkbox"
:checked="journalConfig.closeout_enabled !== false"
@change="onToggleCloseout(($event.target as HTMLInputElement).checked)"
/>
<span>
<strong>Nightly closeout</strong>
<small style="display:block;color:var(--color-text-muted)">Extracts patterns from yesterday's journal at your day-rollover hour.</small>
</span>
</label>
<div v-if="profile.learned_summary" class="learned-summary">{{ profile.learned_summary }}</div>
<div v-else class="learned-empty">No learned summary yet. Observations accumulate from journal and chat conversations.</div>
<div class="observations-panel" style="margin-top:0.75rem">
<button class="btn-secondary" @click="toggleObservations" :disabled="profile.observations_count === 0">
{{ observationsExpanded ? '' : '' }} Recent observations ({{ profile.observations_count }})
</button>
<div v-if="observationsExpanded" class="observations-list" style="margin-top:0.5rem;padding:0.5rem 0.75rem;border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-bg-elev-1)">
<div v-if="observationsLoading">Loading…</div>
<div v-else-if="observations.length === 0">No observations yet.</div>
<div v-else>
<div v-for="entry in observations" :key="entry.date" style="margin-bottom:0.75rem">
<div style="font-weight:600;font-size:0.875rem;color:var(--color-text-muted)">{{ entry.date }}</div>
<div style="white-space:pre-wrap;font-size:0.9rem">{{ entry.bullets }}</div>
</div>
</div>
</div>
</div>
<div class="actions" style="gap:0.5rem;flex-wrap:wrap">
<button class="btn-secondary" @click="runConsolidate" :disabled="consolidating || profile.observations_count === 0">
{{ consolidating ? 'Consolidating' : 'Consolidate Now' }}
+156 -8
View File
@@ -35,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");
@@ -107,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>(() => {
@@ -186,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";
@@ -198,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 ||
@@ -270,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;
@@ -286,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;
@@ -312,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,
@@ -325,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;
@@ -377,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,
@@ -388,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;
@@ -431,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">
@@ -439,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 -->
@@ -459,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"
@@ -471,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>
@@ -971,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>
+54
View File
@@ -357,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"
@@ -807,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>
+10
View File
@@ -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():
+22 -3
View File
@@ -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,
+8
View File
@@ -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:
+26 -19
View File
@@ -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())
+48 -2
View File
@@ -66,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():
@@ -82,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():
@@ -258,7 +301,9 @@ async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> No
@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
@@ -318,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
+36 -2
View File
@@ -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"])
+10
View File
@@ -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:]))})
+32 -4
View File
@@ -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())
+6
View File
@@ -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)
+14 -3
View File
@@ -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
+10 -2
View File
@@ -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,
)
+171 -59
View File
@@ -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)
@@ -79,6 +79,15 @@ MOMENT ENTITY LINKING — be conservative.
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
+9 -1
View File
@@ -178,7 +178,15 @@ async def gather_daily_sections(
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)
@@ -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:
+1
View File
@@ -620,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",
@@ -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
)
+23 -1
View File
@@ -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)
+54 -14
View File
@@ -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
+128 -17
View File
@@ -108,6 +108,88 @@ async def _resolve_event_end(
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.
@@ -149,7 +231,17 @@ def _validate_weekday(start_dt_utc: datetime, user_tz, expected: str | None) ->
"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."
"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"},
@@ -295,10 +387,17 @@ async def search_events_tool(*, user_id, arguments, **_ctx):
"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."
"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_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."},
@@ -315,14 +414,15 @@ async def search_events_tool(*, user_id, arguments, **_ctx):
"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:
@@ -358,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}}
@@ -165,7 +165,11 @@ 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": {
+39 -4
View File
@@ -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."},
+4 -12
View File
@@ -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(
+12 -1
View File
@@ -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")
+16 -5
View File
@@ -232,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())
+182
View File
@@ -577,6 +577,188 @@ async def test_update_event_expected_weekday_mismatch_rejects():
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.
+317
View File
@@ -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"
+201 -4
View File
@@ -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."""
+249
View File
@@ -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()
+118
View File
@@ -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()
+74
View File
@@ -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"
+145
View File
@@ -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
+200
View File
@@ -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"
+58
View File
@@ -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()
+128
View File
@@ -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
+83
View File
@@ -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}"
+161
View File
@@ -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