Compare commits

..
9 Commits
Author SHA1 Message Date
bvandeusen c810d63bee Merge pull request 'MCP plan-prompt tune + Flutter docs removal' (#52) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 24s
2026-06-01 00:13:31 -04:00
bvandeusen a3a056d6fd v26.05.30.1 — MCP-first pivot + Rulebook + Plans + Soft-delete 2026-05-29 22:50:34 -04:00
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 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 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 f85b92a885 Release v26.04.29.3 — Event-store data integrity + EventSlideOver redesign 2026-04-29 20:04:43 +00:00
bvandeusen b81c4aa600 Release v26.04.29.2 — Anti-placeholder rule on create_event 2026-04-29 16:10:53 +00:00
bvandeusen 404698521f Release v26.04.29.1 — Date durability + journal voice tuning 2026-04-29 14:37:45 +00:00
bvandeusen 88b351a96e Merge pull request 'Release v26.04.28.1 — Design system polish, journal config in Profile, doc cleanup' (#44) from dev into main 2026-04-28 13:12:54 +00:00
197 changed files with 3588 additions and 4903 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
POSTGRES_USER=fabled POSTGRES_USER=fabled
POSTGRES_PASSWORD=fabled POSTGRES_PASSWORD=fabled
POSTGRES_DB=scribe POSTGRES_DB=fabledassistant
SECRET_KEY=dev-secret-change-me SECRET_KEY=dev-secret-change-me
+21 -29
View File
@@ -1,19 +1,12 @@
# CI runs first; build only proceeds if all checks pass. # CI runs first; build only proceeds if all checks pass.
# #
# Push to dev: typecheck + lint + test + build :dev + :<sha> # Push to dev: typecheck + lint + test + build :dev + :<sha>
# Push to main: typecheck + lint + test + build :latest + :<sha> # Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
# #
# Both dev and main are gated AND built. dev pushes move :dev; main pushes move # main pushes are NOT gated here: a merge to main only happens after
# :latest — main IS the production line, so :latest tracks main's tip and there # dev has already passed CI, and the release tag is the sole trigger
# is no separate :main tag. Every push also gets an immutable :<sha> (the # for a production image. Re-running CI on the merge commit just burns
# rollback point). A v* release tag additionally publishes the dated :<version>; # runner time without changing the outcome.
# since main already moved :latest, the release tag's distinct job is that
# :<version> marker (it refreshes :latest too, harmlessly).
#
# Successive pushes to the SAME ref supersede each other (see concurrency
# below), so rapid pushes don't stack identical work; dev and main runs are
# independent refs and never cancel one another.
# #
# To cut a release: # To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name. # Create a release via the Forgejo UI on main with a v* tag name.
@@ -23,8 +16,11 @@
# gating on branch push is already enough. # gating on branch push is already enough.
# #
# NOTE on the `if:` guards below: Forgejo Actions does not consistently # NOTE on the `if:` guards below: Forgejo Actions does not consistently
# honor `on.push.branches` as a filter, so every job repeats the ref check # honor `on.push.branches` as a filter — merge commits landing on main
# explicitly — permitting dev, main, and v* tags, rejecting anything else. # still trigger the workflow, producing redundant runs on the same SHA
# that was already gated on dev. Every job therefore repeats the ref
# check so main pushes trigger the workflow but every job skips
# immediately (no runner time, no duplicate work).
# #
# Required secrets (repo → Settings → Secrets → Actions): # Required secrets (repo → Settings → Secrets → Actions):
# REGISTRY_USER — your Forgejo username # REGISTRY_USER — your Forgejo username
@@ -33,7 +29,7 @@ name: CI & Build
on: on:
push: push:
branches: [dev, main] branches: [dev]
tags: ["v*"] tags: ["v*"]
paths: paths:
- "src/**" - "src/**"
@@ -71,8 +67,8 @@ env:
jobs: jobs:
typecheck: typecheck:
name: TypeScript typecheck name: TypeScript typecheck
# Gate dev, main, and v* tags; reject any other ref (see header note). # Skip on main merge-commit pushes — see workflow header comment.
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -96,7 +92,7 @@ jobs:
lint: lint:
name: Python lint name: Python lint
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -110,7 +106,7 @@ jobs:
test: test:
name: Python tests name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -142,9 +138,11 @@ jobs:
build: build:
name: Build & push image name: Build & push image
needs: [typecheck, lint, test] needs: [typecheck, lint, test]
# Build on dev, main, and v* tag pushes. dev → :dev, main → :latest, # Build on dev branch pushes and version tag pushes only.
# tag → :latest + :<version>; every build also gets an immutable :<sha>. # Mirrors the ref guard on the gate jobs above — main merge-commit
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') # pushes skip here too, so no production image is ever built from a
# raw main push (only from the v* tag the release creates).
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -170,12 +168,6 @@ jobs:
refs/heads/dev) refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev" TAGS="$TAGS,${{ env.IMAGE }}:dev"
;; ;;
refs/heads/main)
# main IS the production line: publish :latest (plus the :<sha>
# set above). No separate :main tag.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
BUILD_VERSION="main"
;;
refs/tags/*) refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
BUILD_VERSION="${{ github.ref_name }}" BUILD_VERSION="${{ github.ref_name }}"
+1 -1
View File
@@ -1 +1 @@
1425947 632037
+2 -2
View File
@@ -17,7 +17,7 @@ COPY src/ src/
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
pip install . pip install .
COPY --from=build-frontend /build/dist/ src/scribe/static/ COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
COPY alembic.ini . COPY alembic.ini .
COPY alembic/ alembic/ COPY alembic/ alembic/
@@ -30,4 +30,4 @@ ARG BUILD_VERSION=dev
ENV APP_VERSION=$BUILD_VERSION ENV APP_VERSION=$BUILD_VERSION
EXPOSE 5000 EXPOSE 5000
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] CMD ["sh", "-c", "alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
+2 -2
View File
@@ -4,8 +4,8 @@ from logging.config import fileConfig
from alembic import context from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from scribe.config import Config from fabledassistant.config import Config
from scribe.models import Base from fabledassistant.models import Base
config = context.config config = context.config
if config.config_file_name is not None: if config.config_file_name is not None:
@@ -1,39 +0,0 @@
"""rulebook always_on flag
Revision ID: 0058
Revises: 0057
Create Date: 2026-06-01
Adds a boolean `always_on` to the `rulebooks` table. Rules from rulebooks
flagged always_on are loaded at session start by the new
`list_always_on_rules` MCP tool — they apply regardless of which project
(if any) is in scope. Seeds the FabledSword family rulebook to always_on
because that's the cross-project standards rulebook by design.
"""
from alembic import op
import sqlalchemy as sa
revision = "0058"
down_revision = "0057"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"rulebooks",
sa.Column(
"always_on",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
op.execute(
"UPDATE rulebooks SET always_on = TRUE WHERE title = 'FabledSword family'"
)
def downgrade() -> None:
op.drop_column("rulebooks", "always_on")
@@ -1,49 +0,0 @@
"""project-scoped rules
Revision ID: 0059
Revises: 0058
Create Date: 2026-06-01
Rules can now belong to either a rulebook topic (cross-project standard) or
a single project (project-scoped). Adds `rules.project_id`, makes `topic_id`
nullable, and adds a CHECK constraint enforcing exactly-one. The previous
unique constraint on (topic_id, title) still applies because PostgreSQL
treats NULL as distinct — two project-scoped rules with the same title and
NULL topic_id remain unique.
"""
from alembic import op
import sqlalchemy as sa
revision = "0059"
down_revision = "0058"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"rules",
sa.Column(
"project_id",
sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=True,
),
)
op.alter_column("rules", "topic_id", nullable=True)
op.create_index("ix_rules_project_id", "rules", ["project_id"])
op.create_check_constraint(
"ck_rule_topic_xor_project",
"rules",
"(topic_id IS NULL) <> (project_id IS NULL)",
)
def downgrade() -> None:
op.drop_constraint("ck_rule_topic_xor_project", "rules", type_="check")
op.drop_index("ix_rules_project_id", table_name="rules")
# Any rule with NULL topic_id will block re-tightening. Operator must
# migrate or delete project-scoped rules before downgrading.
op.alter_column("rules", "topic_id", nullable=False)
op.drop_column("rules", "project_id")
@@ -1,73 +0,0 @@
"""project rule + topic suppressions
Revision ID: 0060
Revises: 0059
Create Date: 2026-06-01
Lets a project mute specific rules or whole topics from rulebooks it
subscribes to, without unsubscribing the rulebook. Two pure many-to-many
association tables; FKs CASCADE so removing a project / rule / topic
cleans the suppression rows automatically.
"""
from alembic import op
import sqlalchemy as sa
revision = "0060"
down_revision = "0059"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"project_rule_suppressions",
sa.Column(
"project_id",
sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
sa.Column(
"rule_id",
sa.BigInteger(),
sa.ForeignKey("rules.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
)
op.create_table(
"project_topic_suppressions",
sa.Column(
"project_id",
sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
sa.Column(
"topic_id",
sa.BigInteger(),
sa.ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("project_topic_suppressions")
op.drop_table("project_rule_suppressions")
@@ -1,49 +0,0 @@
"""partial-unique topic/rule titles (ignore soft-deleted rows)
Revision ID: 0061
Revises: 0060
Create Date: 2026-06-02
Topics and rules are soft-deleted (SoftDeleteMixin), but uq_topic_per_rulebook
and uq_rule_per_topic were plain UNIQUE constraints. Trashing a topic/rule
named "X" then creating a new "X" — or restoring into a reused title slot —
collided with the dead row and raised an unhandled 500. Replace the full
UNIQUE constraints with partial unique indexes that only consider live
(deleted_at IS NULL) rows.
"""
from alembic import op
import sqlalchemy as sa
revision = "0061"
down_revision = "0060"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_constraint("uq_topic_per_rulebook", "rulebook_topics", type_="unique")
op.create_index(
"uq_topic_per_rulebook",
"rulebook_topics",
["rulebook_id", "title"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
op.drop_constraint("uq_rule_per_topic", "rules", type_="unique")
op.create_index(
"uq_rule_per_topic",
"rules",
["topic_id", "title"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
def downgrade() -> None:
op.drop_index("uq_rule_per_topic", table_name="rules")
op.create_unique_constraint("uq_rule_per_topic", "rules", ["topic_id", "title"])
op.drop_index("uq_topic_per_rulebook", table_name="rulebook_topics")
op.create_unique_constraint(
"uq_topic_per_rulebook", "rulebook_topics", ["rulebook_id", "title"]
)
@@ -1,41 +0,0 @@
"""drop dead UserProfile curator columns
Revision ID: 0062
Revises: 0061
Create Date: 2026-06-02
learned_summary, observations_raw, and observations_updated_at were written by
the curator/LLM-profile machinery removed in the Phase-8 MCP pivot. Nothing has
populated them since; the profile API returned permanently-empty fields. Drop
the columns.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0062"
down_revision = "0061"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("user_profiles", "learned_summary")
op.drop_column("user_profiles", "observations_raw")
op.drop_column("user_profiles", "observations_updated_at")
def downgrade() -> None:
op.add_column(
"user_profiles",
sa.Column("observations_updated_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"user_profiles",
sa.Column("observations_raw", postgresql.JSONB(), nullable=True),
)
op.add_column(
"user_profiles",
sa.Column("learned_summary", sa.Text(), nullable=True),
)
@@ -1,34 +0,0 @@
"""drop dead Project.auto_summary columns
Revision ID: 0063
Revises: 0062
Create Date: 2026-06-03
auto_summary + summary_updated_at were written by generate_project_summary
(Ollama), removed in the MCP pivot. Nothing has populated them since; the
field was stale-or-NULL. Drop the columns.
"""
from alembic import op
import sqlalchemy as sa
revision = "0063"
down_revision = "0062"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("projects", "auto_summary")
op.drop_column("projects", "summary_updated_at")
def downgrade() -> None:
op.add_column(
"projects",
sa.Column("summary_updated_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"projects",
sa.Column("auto_summary", sa.Text(), nullable=True),
)
+8 -8
View File
@@ -1,14 +1,14 @@
services: services:
app: app:
image: git.fabledsword.com/bvandeusen/fabledscribe:latest image: git.fabledsword.com/bvandeusen/fabledassistant:latest
environment: environment:
DATABASE_URL: "postgresql+asyncpg://scribe:${DB_PASSWORD}@db:5432/scribe" DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant"
SECRET_KEY: "${SECRET_KEY}" SECRET_KEY: "${SECRET_KEY}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}" LOG_LEVEL: "${LOG_LEVEL:-INFO}"
TRUST_PROXY_HEADERS: "true" TRUST_PROXY_HEADERS: "true"
SECURE_COOKIES: "true" SECURE_COOKIES: "true"
networks: networks:
- scribe_backend - fabledassistant_backend
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 30s interval: 30s
@@ -26,16 +26,16 @@ services:
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_USER: scribe POSTGRES_USER: fabled
POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: scribe POSTGRES_DB: fabledassistant
networks: networks:
- scribe_backend - fabledassistant_backend
# Lenient by design: a transient host exec/healthcheck stall (incident: # Lenient by design: a transient host exec/healthcheck stall (incident:
# runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never # runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never
# escalate to killing the DB. Health here only gates app startup order. # escalate to killing the DB. Health here only gates app startup order.
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U scribe"] test: ["CMD-SHELL", "pg_isready -U fabled"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 10 retries: 10
@@ -51,5 +51,5 @@ volumes:
pgdata: pgdata:
networks: networks:
scribe_backend: fabledassistant_backend:
driver: overlay driver: overlay
+5 -5
View File
@@ -18,7 +18,7 @@ services:
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
DATABASE_URL: "postgresql+asyncpg://scribe:scribe@db:5432/scribe" DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant"
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}" SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}" LOG_LEVEL: "${LOG_LEVEL:-INFO}"
volumes: volumes:
@@ -40,13 +40,13 @@ services:
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_USER: scribe POSTGRES_USER: fabled
POSTGRES_PASSWORD: scribe POSTGRES_PASSWORD: fabled
POSTGRES_DB: scribe POSTGRES_DB: fabledassistant
# Lenient by design: a transient host exec/healthcheck stall must never # Lenient by design: a transient host exec/healthcheck stall must never
# escalate to killing the DB. Health here only gates app startup order. # escalate to killing the DB. Health here only gates app startup order.
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U scribe"] test: ["CMD-SHELL", "pg_isready -U fabled"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 10 retries: 10
+5 -5
View File
@@ -11,7 +11,7 @@ services:
# To use a bind mount instead (gives direct host access to all app data): # To use a bind mount instead (gives direct host access to all app data):
# - ./data:/data # - ./data:/data
environment: environment:
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-scribe}:${POSTGRES_PASSWORD:-scribe}@db:5432/${POSTGRES_DB:-scribe}" DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
# Uncomment if you have a SearXNG instance you want to surface in the # Uncomment if you have a SearXNG instance you want to surface in the
# Integrations tab as a configured web-search backend: # Integrations tab as a configured web-search backend:
@@ -30,13 +30,13 @@ services:
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-scribe} POSTGRES_USER: ${POSTGRES_USER:-fabled}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scribe} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabled}
POSTGRES_DB: ${POSTGRES_DB:-scribe} POSTGRES_DB: ${POSTGRES_DB:-fabledassistant}
# Lenient by design: a transient host exec/healthcheck stall must never # Lenient by design: a transient host exec/healthcheck stall must never
# escalate to killing the DB. Health here only gates app startup order. # escalate to killing the DB. Health here only gates app startup order.
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scribe}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fabled}"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 10 retries: 10
+3 -3
View File
@@ -12,7 +12,7 @@ A Python MCP (Model Context Protocol) server that lets Claude directly interface
The work is split into two sub-projects: The work is split into two sub-projects:
1. **Fable API Key Feature** — additions to the main `scribe` project to support bearer token authentication 1. **Fable API Key Feature** — additions to the main `fabledassistant` project to support bearer token authentication
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root 2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working. A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
@@ -134,7 +134,7 @@ The MCP tool reads tokens until it receives `type: "done"`, then returns `respon
### Location ### Location
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `scribe`. It will be extracted to its own Forgejo repo once stable. `fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `fabledassistant`. It will be extracted to its own Forgejo repo once stable.
### Package Structure ### Package Structure
@@ -256,7 +256,7 @@ Claude Code spawns the process over stdio automatically. No Docker, no daemon.
## Build & Repo Plan ## Build & Repo Plan
1. Implement and test within `scribe/fable-mcp/` 1. Implement and test within `fabledassistant/fable-mcp/`
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`) 2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready 3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
+66 -66
View File
@@ -6,7 +6,7 @@
**Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done. **Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done.
**Deployment decision:** `fable-mcp/` stays permanently inside the `scribe` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance. **Deployment decision:** `fable-mcp/` stays permanently inside the `fabledassistant` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance.
**Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both. **Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both.
@@ -23,16 +23,16 @@
| Action | File | Purpose | | Action | File | Purpose |
|--------|------|---------| |--------|------|---------|
| Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table | | Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table |
| Create | `src/scribe/models/api_key.py` | `ApiKey` SQLAlchemy model | | Create | `src/fabledassistant/models/api_key.py` | `ApiKey` SQLAlchemy model |
| Modify | `src/scribe/models/__init__.py` | Export `ApiKey` | | Modify | `src/fabledassistant/models/__init__.py` | Export `ApiKey` |
| Create | `src/scribe/services/api_keys.py` | create/list/revoke/lookup service functions | | Create | `src/fabledassistant/services/api_keys.py` | create/list/revoke/lookup service functions |
| Modify | `src/scribe/auth.py` | Add bearer token check before session fallback | | Modify | `src/fabledassistant/auth.py` | Add bearer token check before session fallback |
| Create | `src/scribe/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint | | Create | `src/fabledassistant/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint |
| Modify | `src/scribe/app.py` | Register `api_keys_bp` and `search_bp` | | Modify | `src/fabledassistant/app.py` | Register `api_keys_bp` and `search_bp` |
| Modify | `src/scribe/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` | | Modify | `src/fabledassistant/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` |
| Modify | `src/scribe/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` | | Modify | `src/fabledassistant/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` |
| Modify | `src/scribe/routes/chat.py:73-79` | Pass `conversation_type` from POST body | | Modify | `src/fabledassistant/routes/chat.py:73-79` | Pass `conversation_type` from POST body |
| Create | `src/scribe/routes/search.py` | `GET /api/search` semantic search endpoint | | Create | `src/fabledassistant/routes/search.py` | `GET /api/search` semantic search endpoint |
| Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab | | Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab |
| Create | `tests/test_api_keys.py` | Unit tests for service + auth | | Create | `tests/test_api_keys.py` | Unit tests for service + auth |
| Create | `tests/test_search_route.py` | Unit test for search endpoint | | Create | `tests/test_search_route.py` | Unit test for search endpoint |
@@ -42,13 +42,13 @@
### Task 1: ApiKey model + migration ### Task 1: ApiKey model + migration
**Files:** **Files:**
- Create: `src/scribe/models/api_key.py` - Create: `src/fabledassistant/models/api_key.py`
- Create: `alembic/versions/0027_add_api_keys.py` - Create: `alembic/versions/0027_add_api_keys.py`
- Modify: `src/scribe/models/__init__.py` - Modify: `src/fabledassistant/models/__init__.py`
- [ ] **Step 1: Write the model** - [ ] **Step 1: Write the model**
Create `src/scribe/models/api_key.py`: Create `src/fabledassistant/models/api_key.py`:
```python ```python
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -56,8 +56,8 @@ from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import CreatedAtMixin from fabledassistant.models.base import CreatedAtMixin
class ApiKey(Base, CreatedAtMixin): class ApiKey(Base, CreatedAtMixin):
@@ -97,10 +97,10 @@ class ApiKey(Base, CreatedAtMixin):
- [ ] **Step 2: Export from models __init__** - [ ] **Step 2: Export from models __init__**
In `src/scribe/models/__init__.py`, add after the last import line: In `src/fabledassistant/models/__init__.py`, add after the last import line:
```python ```python
from scribe.models.api_key import ApiKey # noqa: E402, F401 from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
``` ```
- [ ] **Step 3: Write the migration** - [ ] **Step 3: Write the migration**
@@ -155,8 +155,8 @@ Expected: `Running upgrade 0026 -> 0027, add api_keys table`
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
git add src/scribe/models/api_key.py \ git add src/fabledassistant/models/api_key.py \
src/scribe/models/__init__.py \ src/fabledassistant/models/__init__.py \
alembic/versions/0027_add_api_keys.py alembic/versions/0027_add_api_keys.py
git commit -m "feat: add ApiKey model and migration 0027" git commit -m "feat: add ApiKey model and migration 0027"
``` ```
@@ -166,7 +166,7 @@ git commit -m "feat: add ApiKey model and migration 0027"
### Task 2: ApiKey service ### Task 2: ApiKey service
**Files:** **Files:**
- Create: `src/scribe/services/api_keys.py` - Create: `src/fabledassistant/services/api_keys.py`
- Create: `tests/test_api_keys.py` (service tests) - Create: `tests/test_api_keys.py` (service tests)
- [ ] **Step 1: Write the failing tests** - [ ] **Step 1: Write the failing tests**
@@ -181,7 +181,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from scribe.services.api_keys import ( from fabledassistant.services.api_keys import (
_hash_key, _hash_key,
generate_key, generate_key,
create_api_key, create_api_key,
@@ -212,7 +212,7 @@ def test_hash_key_is_sha256():
def test_generate_key_prefix(): def test_generate_key_prefix():
key = "fmcp_abcdefghijklmnop" key = "fmcp_abcdefghijklmnop"
# prefix is first 12 chars of the full key # prefix is first 12 chars of the full key
from scribe.services.api_keys import _key_prefix from fabledassistant.services.api_keys import _key_prefix
assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars
@@ -222,7 +222,7 @@ async def test_create_api_key_returns_full_key():
mock_key_obj.id = 1 mock_key_obj.id = 1
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"} mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
with patch("scribe.services.api_keys.async_session") as mock_session_ctx: with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx:
mock_session = AsyncMock() mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False) mock_session.__aexit__ = AsyncMock(return_value=False)
@@ -238,7 +238,7 @@ async def test_create_api_key_returns_full_key():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_lookup_key_returns_none_for_unknown(): async def test_lookup_key_returns_none_for_unknown():
with patch("scribe.services.api_keys.async_session") as mock_session_ctx: with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx:
mock_session = AsyncMock() mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False) mock_session.__aexit__ = AsyncMock(return_value=False)
@@ -262,7 +262,7 @@ Expected: `ImportError` or `ModuleNotFoundError` (service doesn't exist yet)
- [ ] **Step 3: Write the service** - [ ] **Step 3: Write the service**
Create `src/scribe/services/api_keys.py`: Create `src/fabledassistant/services/api_keys.py`:
```python ```python
import hashlib import hashlib
@@ -271,8 +271,8 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.api_key import ApiKey from fabledassistant.models.api_key import ApiKey
def generate_key() -> str: def generate_key() -> str:
@@ -365,7 +365,7 @@ Expected: all tests pass
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
git add src/scribe/services/api_keys.py tests/test_api_keys.py git add src/fabledassistant/services/api_keys.py tests/test_api_keys.py
git commit -m "feat: add ApiKey service with create/list/revoke/lookup" git commit -m "feat: add ApiKey service with create/list/revoke/lookup"
``` ```
@@ -374,7 +374,7 @@ git commit -m "feat: add ApiKey service with create/list/revoke/lookup"
### Task 3: Auth middleware — bearer token support ### Task 3: Auth middleware — bearer token support
**Files:** **Files:**
- Modify: `src/scribe/auth.py` - Modify: `src/fabledassistant/auth.py`
- Modify: `tests/test_api_keys.py` (add auth middleware tests) - Modify: `tests/test_api_keys.py` (add auth middleware tests)
- [ ] **Step 1: Add auth middleware tests** - [ ] **Step 1: Add auth middleware tests**
@@ -400,7 +400,7 @@ def test_scope_validation():
async def test_bearer_token_path_sets_g_user(monkeypatch): async def test_bearer_token_path_sets_g_user(monkeypatch):
"""Valid bearer token authenticates and sets g.user and g.api_key.""" """Valid bearer token authenticates and sets g.user and g.api_key."""
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from scribe.auth import _check_auth from fabledassistant.auth import _check_auth
# Mock ApiKey object # Mock ApiKey object
fake_key = MagicMock() fake_key = MagicMock()
@@ -411,8 +411,8 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
fake_user = MagicMock() fake_user = MagicMock()
fake_user.role = "user" fake_user.role = "user"
monkeypatch.setattr("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)) monkeypatch.setattr("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key))
monkeypatch.setattr("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)) monkeypatch.setattr("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user))
called_with_user = {} called_with_user = {}
@@ -434,7 +434,7 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
async with app.test_request_context("/test", method="GET", async with app.test_request_context("/test", method="GET",
headers={"Authorization": "Bearer fmcp_valid"}): headers={"Authorization": "Bearer fmcp_valid"}):
# Just verify _check_auth calls lookup_key with the right token # Just verify _check_auth calls lookup_key with the right token
import scribe.auth as auth_module import fabledassistant.auth as auth_module
auth_module.lookup_key.assert_called_with # callable auth_module.lookup_key.assert_called_with # callable
@@ -442,7 +442,7 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
async def test_read_only_key_blocked_on_post(): async def test_read_only_key_blocked_on_post():
"""Read-only API key returns 403 on non-GET requests.""" """Read-only API key returns 403 on non-GET requests."""
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from scribe.auth import _check_auth from fabledassistant.auth import _check_auth
from quart import Quart from quart import Quart
fake_key = MagicMock() fake_key = MagicMock()
@@ -459,8 +459,8 @@ async def test_read_only_key_blocked_on_post():
headers={"Authorization": "Bearer fmcp_readonly"}), headers={"Authorization": "Bearer fmcp_readonly"}),
): ):
from unittest.mock import patch from unittest.mock import patch
with patch("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)), \ with patch("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)), \
patch("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)): patch("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
async def dummy(): async def dummy():
return "ok" return "ok"
@@ -481,15 +481,15 @@ docker compose run --rm app pytest tests/test_api_keys.py -v
- [ ] **Step 3: Update auth.py** - [ ] **Step 3: Update auth.py**
Replace `src/scribe/auth.py` with: Replace `src/fabledassistant/auth.py` with:
```python ```python
import functools import functools
from quart import g, jsonify, request, session from quart import g, jsonify, request, session
from scribe.services.auth import get_user_by_id from fabledassistant.services.auth import get_user_by_id
from scribe.services.api_keys import lookup_key from fabledassistant.services.api_keys import lookup_key
def _check_auth(f, required_role: str | None = None): def _check_auth(f, required_role: str | None = None):
@@ -556,7 +556,7 @@ Expected: all existing tests still pass
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
git add src/scribe/auth.py tests/test_api_keys.py git add src/fabledassistant/auth.py tests/test_api_keys.py
git commit -m "feat: add bearer token auth to _check_auth, falls back to session" git commit -m "feat: add bearer token auth to _check_auth, falls back to session"
``` ```
@@ -565,18 +565,18 @@ git commit -m "feat: add bearer token auth to _check_auth, falls back to session
### Task 4: API key routes + app registration ### Task 4: API key routes + app registration
**Files:** **Files:**
- Create: `src/scribe/routes/api_keys.py` - Create: `src/fabledassistant/routes/api_keys.py`
- Modify: `src/scribe/app.py` - Modify: `src/fabledassistant/app.py`
- [ ] **Step 1: Write the routes** - [ ] **Step 1: Write the routes**
Create `src/scribe/routes/api_keys.py`: Create `src/fabledassistant/routes/api_keys.py`:
```python ```python
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys") api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
@@ -619,10 +619,10 @@ async def revoke_key_route(key_id: int):
- [ ] **Step 2: Register in app.py** - [ ] **Step 2: Register in app.py**
In `src/scribe/app.py`, add the import alongside the other route imports: In `src/fabledassistant/app.py`, add the import alongside the other route imports:
```python ```python
from scribe.routes.api_keys import api_keys_bp from fabledassistant.routes.api_keys import api_keys_bp
``` ```
And add the registration line after `app.register_blueprint(users_bp)`: And add the registration line after `app.register_blueprint(users_bp)`:
@@ -651,7 +651,7 @@ curl -s http://localhost:8080/api/auth/me \
- [ ] **Step 4: Commit** - [ ] **Step 4: Commit**
```bash ```bash
git add src/scribe/routes/api_keys.py src/scribe/app.py git add src/fabledassistant/routes/api_keys.py src/fabledassistant/app.py
git commit -m "feat: add API key CRUD routes and register blueprint" git commit -m "feat: add API key CRUD routes and register blueprint"
``` ```
@@ -660,14 +660,14 @@ git commit -m "feat: add API key CRUD routes and register blueprint"
### Task 5: Conversation type wiring ### Task 5: Conversation type wiring
**Files:** **Files:**
- Modify: `src/scribe/services/chat.py` (lines 17-30 and 123-135) - Modify: `src/fabledassistant/services/chat.py` (lines 17-30 and 123-135)
- Modify: `src/scribe/routes/chat.py` (lines 73-79) - Modify: `src/fabledassistant/routes/chat.py` (lines 73-79)
Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion. Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion.
- [ ] **Step 1: Update `create_conversation` service** - [ ] **Step 1: Update `create_conversation` service**
In `src/scribe/services/chat.py`, change the function signature at line 17: In `src/fabledassistant/services/chat.py`, change the function signature at line 17:
```python ```python
async def create_conversation( async def create_conversation(
@@ -692,7 +692,7 @@ async def create_conversation(
- [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"** - [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"**
In `src/scribe/services/chat.py`, update the WHERE clause at line 130: In `src/fabledassistant/services/chat.py`, update the WHERE clause at line 130:
```python ```python
result = await session.execute( result = await session.execute(
@@ -708,7 +708,7 @@ result = await session.execute(
- [ ] **Step 3: Update the POST route to accept conversation_type** - [ ] **Step 3: Update the POST route to accept conversation_type**
In `src/scribe/routes/chat.py`, update `create_conversation_route` (around line 73): In `src/fabledassistant/routes/chat.py`, update `create_conversation_route` (around line 73):
```python ```python
@chat_bp.route("/conversations", methods=["POST"]) @chat_bp.route("/conversations", methods=["POST"])
@@ -737,7 +737,7 @@ Expected: all tests pass
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
git add src/scribe/services/chat.py src/scribe/routes/chat.py git add src/fabledassistant/services/chat.py src/fabledassistant/routes/chat.py
git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep" git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep"
``` ```
@@ -746,8 +746,8 @@ git commit -m "feat: wire conversation_type through create_conversation, exclude
### Task 6: Semantic search endpoint ### Task 6: Semantic search endpoint
**Files:** **Files:**
- Create: `src/scribe/routes/search.py` - Create: `src/fabledassistant/routes/search.py`
- Modify: `src/scribe/app.py` - Modify: `src/fabledassistant/app.py`
- Create: `tests/test_search_route.py` - Create: `tests/test_search_route.py`
- [ ] **Step 1: Write the failing test** - [ ] **Step 1: Write the failing test**
@@ -762,7 +762,7 @@ from unittest.mock import patch, AsyncMock
def test_content_type_mapping(): def test_content_type_mapping():
"""Verify content_type string maps to correct is_task value.""" """Verify content_type string maps to correct is_task value."""
from scribe.routes.search import _content_type_to_is_task from fabledassistant.routes.search import _content_type_to_is_task
assert _content_type_to_is_task("note") is False assert _content_type_to_is_task("note") is False
assert _content_type_to_is_task("task") is True assert _content_type_to_is_task("task") is True
assert _content_type_to_is_task("all") is None assert _content_type_to_is_task("all") is None
@@ -779,13 +779,13 @@ Expected: `ImportError` (module doesn't exist yet)
- [ ] **Step 3: Write the route** - [ ] **Step 3: Write the route**
Create `src/scribe/routes/search.py`: Create `src/fabledassistant/routes/search.py`:
```python ```python
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.services.embeddings import semantic_search_notes from fabledassistant.services.embeddings import semantic_search_notes
search_bp = Blueprint("search", __name__, url_prefix="/api/search") search_bp = Blueprint("search", __name__, url_prefix="/api/search")
@@ -834,9 +834,9 @@ Note: check `services/embeddings.py` to confirm the return type of `semantic_sea
- [ ] **Step 4: Register in app.py** - [ ] **Step 4: Register in app.py**
Add to imports in `src/scribe/app.py`: Add to imports in `src/fabledassistant/app.py`:
```python ```python
from scribe.routes.search import search_bp from fabledassistant.routes.search import search_bp
``` ```
Add registration: Add registration:
@@ -855,8 +855,8 @@ Expected: all tests pass
- [ ] **Step 6: Commit** - [ ] **Step 6: Commit**
```bash ```bash
git add src/scribe/routes/search.py \ git add src/fabledassistant/routes/search.py \
src/scribe/app.py \ src/fabledassistant/app.py \
tests/test_search_route.py tests/test_search_route.py
git commit -m "feat: add GET /api/search semantic search endpoint" git commit -m "feat: add GET /api/search semantic search endpoint"
``` ```
@@ -1905,7 +1905,7 @@ async def send_message(
return f"Error: {e}" return f"Error: {e}"
``` ```
Note: verify the Fable message POST endpoint path by checking `src/scribe/routes/chat.py` — search for the route that accepts a user message and triggers generation. Adjust `/api/chat/conversations/{conv_id}/messages` if the actual path differs. Note: verify the Fable message POST endpoint path by checking `src/fabledassistant/routes/chat.py` — search for the route that accepts a user message and triggers generation. Adjust `/api/chat/conversations/{conv_id}/messages` if the actual path differs.
- [ ] **Step 4: Run tests** - [ ] **Step 4: Run tests**
+9 -9
View File
@@ -39,20 +39,20 @@
## New Backend Files ## New Backend Files
### `src/scribe/services/stt.py` ### `src/fabledassistant/services/stt.py`
Lazy singleton `WhisperModel` loader. Public API: Lazy singleton `WhisperModel` loader. Public API:
- `load_stt_model()` — called at startup via `asyncio.create_task` - `load_stt_model()` — called at startup via `asyncio.create_task`
- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text - `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text
- `stt_available() -> bool` - `stt_available() -> bool`
### `src/scribe/services/tts.py` ### `src/fabledassistant/services/tts.py`
Lazy singleton `KPipeline` loader. Public API: Lazy singleton `KPipeline` loader. Public API:
- `load_tts_model()` — called at startup - `load_tts_model()` — called at startup
- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono) - `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono)
- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels - `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels
- `tts_available() -> bool` - `tts_available() -> bool`
### `src/scribe/routes/voice.py` ### `src/fabledassistant/routes/voice.py`
Blueprint at `/api/voice`, all routes `@login_required`. Blueprint at `/api/voice`, all routes `@login_required`.
| Endpoint | Method | Description | | Endpoint | Method | Description |
@@ -66,27 +66,27 @@ Blueprint at `/api/voice`, all routes `@login_required`.
## Modified Backend Files ## Modified Backend Files
### `src/scribe/app.py` ### `src/fabledassistant/app.py`
- Register `voice_bp` blueprint - Register `voice_bp` blueprint
- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED` - In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED`
### `src/scribe/config.py` ### `src/fabledassistant/config.py`
- Add 4 new env var attributes - Add 4 new env var attributes
- Add validation in `validate()` - Add validation in `validate()`
### `src/scribe/services/llm.py` ### `src/fabledassistant/services/llm.py`
- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()` - Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()`
- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."* - When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."*
- Append style modifier based on `voice_speech_style` - Append style modifier based on `voice_speech_style`
### `src/scribe/services/generation_task.py` ### `src/fabledassistant/services/generation_task.py`
- Add `voice_mode: bool = False` to `run_generation()` - Add `voice_mode: bool = False` to `run_generation()`
- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()` - Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()`
### `src/scribe/routes/chat.py` ### `src/fabledassistant/routes/chat.py`
- Allow `"voice"` in `conversation_type` whitelist - Allow `"voice"` in `conversation_type` whitelist
### `src/scribe/services/chat.py` ### `src/fabledassistant/services/chat.py`
- Exclude `conversation_type == "voice"` from auto-cleanup retention - Exclude `conversation_type == "voice"` from auto-cleanup retention
--- ---
+4 -4
View File
@@ -20,7 +20,7 @@
│ Docker Compose │ │ Docker Compose │
│ │ │ │
│ ┌──────────────────────┐ ┌────────────┐ │ │ ┌──────────────────────┐ ┌────────────┐ │
│ │ scribe │ │ ollama │ │ │ │ fabledassistant │ │ ollama │ │
│ │ ┌────────────────┐ │ │ │ │ │ │ ┌────────────────┐ │ │ │ │
│ │ │ Quart Server │ │ │ LLM API │ │ │ │ │ Quart Server │ │ │ LLM API │ │
│ │ │ ┌──────────┐ │ │ │ │ │ │ │ │ ┌──────────┐ │ │ │ │ │
@@ -43,7 +43,7 @@
## Project Structure ## Project Structure
``` ```
scribe/ fabledassistant/
├── docker-compose.yml # Development stack ├── docker-compose.yml # Development stack
├── docker-compose.prod.yml # Production stack (Docker Swarm) ├── docker-compose.prod.yml # Production stack (Docker Swarm)
├── Dockerfile # Multi-stage build (Node → Python) ├── Dockerfile # Multi-stage build (Node → Python)
@@ -54,7 +54,7 @@ scribe/
│ ├── server.py # FastMCP tool registrations │ ├── server.py # FastMCP tool registrations
│ ├── client.py # FableClient (httpx wrapper) │ ├── client.py # FableClient (httpx wrapper)
│ └── tools/ # Tool modules (notes, tasks, projects, …) │ └── tools/ # Tool modules (notes, tasks, projects, …)
├── src/scribe/ ├── src/fabledassistant/
│ ├── app.py # Quart app factory + blueprint registration │ ├── app.py # Quart app factory + blueprint registration
│ ├── config.py # Config class (reads env vars) │ ├── config.py # Config class (reads env vars)
│ ├── auth.py # login_required decorator, session checks │ ├── auth.py # login_required decorator, session checks
@@ -169,7 +169,7 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
## Detailed File Reference ## Detailed File Reference
### Backend (`src/scribe/`) ### Backend (`src/fabledassistant/`)
| File | Responsibility | | File | Responsibility |
|------|---------------| |------|---------------|
+2 -2
View File
@@ -8,7 +8,7 @@ Configuration is via environment variables. The `docker-compose.yml` file sets d
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/scribe` | PostgreSQL async connection string | | `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/fabledassistant` | PostgreSQL async connection string |
| `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** | | `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** |
| `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) | | `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) |
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | | `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
@@ -91,7 +91,7 @@ The production compose file adds:
```bash ```bash
# Create Docker secrets # Create Docker secrets
echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key - echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key -
echo "postgresql+asyncpg://fabled:strongpassword@db/scribe" | docker secret create fabled_db_url - echo "postgresql+asyncpg://fabled:strongpassword@db/fabledassistant" | docker secret create fabled_db_url -
# Deploy # Deploy
docker stack deploy -c docker-compose.prod.yml fabled docker stack deploy -c docker-compose.prod.yml fabled
+3 -3
View File
@@ -93,7 +93,7 @@ config on the runner host.
### Docker Registry ### Docker Registry
Images pushed to: `git.fabledsword.com/bvandeusen/scribe` Images pushed to: `git.fabledsword.com/bvandeusen/fabledassistant`
Cache tag: `:cache` (reduces build time ~80%) Cache tag: `:cache` (reduces build time ~80%)
Required secrets (repo → Settings → Secrets → Actions): Required secrets (repo → Settings → Secrets → Actions):
@@ -140,8 +140,8 @@ Current migration sequence (all idempotent raw SQL):
### Backend ### Backend
- Services: `async with async_session() as session:` — import from `scribe.models` - Services: `async with async_session() as session:` — import from `fabledassistant.models`
- No `scribe.database` module - No `fabledassistant.database` module
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc. - Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
- All business logic in `services/`; routes are thin wrappers - All business logic in `services/`; routes are thin wrappers
- Permission checks via `services/access.py` — never inline ownership checks in routes - Permission checks via `services/access.py` — never inline ownership checks in routes
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "scribe-frontend", "name": "fabledassistant-frontend",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "scribe-frontend", "name": "fabledassistant-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fullcalendar/core": "^6.1.20", "@fullcalendar/core": "^6.1.20",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "scribe-frontend", "name": "fabledassistant-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
+3 -51
View File
@@ -5,7 +5,6 @@ export interface Rulebook {
owner_user_id: number; owner_user_id: number;
title: string; title: string;
description: string; description: string;
always_on: boolean;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
} }
@@ -22,8 +21,7 @@ export interface RulebookTopic {
export interface Rule { export interface Rule {
id: number; id: number;
topic_id: number | null; topic_id: number;
project_id: number | null;
title: string; title: string;
statement: string; statement: string;
why: string; why: string;
@@ -37,7 +35,7 @@ export interface RuleHeader {
id: number; id: number;
title: string; title: string;
statement: string; statement: string;
topic_id: number | null; topic_id: number;
} }
export interface ApplicableRules { export interface ApplicableRules {
@@ -45,28 +43,7 @@ export interface ApplicableRules {
id: number; id: number;
title: string; title: string;
statement: string; statement: string;
topic_id: number;
topic_title: string; topic_title: string;
rulebook_id: number;
rulebook_title: string;
}[];
project_rules: {
id: number;
title: string;
statement: string;
}[];
suppressed_rules: {
id: number;
title: string;
topic_id: number;
topic_title: string;
rulebook_id: number;
rulebook_title: string;
}[];
suppressed_topics: {
id: number;
title: string;
rulebook_id: number;
rulebook_title: string; rulebook_title: string;
}[]; }[];
truncated: boolean; truncated: boolean;
@@ -88,7 +65,7 @@ export async function createRulebook(data: { title: string; description?: string
return apiPost("/api/rulebooks", data); return apiPost("/api/rulebooks", data);
} }
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise<Rulebook> { export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise<Rulebook> {
return apiPatch(`/api/rulebooks/${id}`, data); return apiPatch(`/api/rulebooks/${id}`, data);
} }
@@ -156,28 +133,3 @@ export async function unsubscribeProject(projectId: number, rulebookId: number):
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> { export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
return apiGet(`/api/projects/${projectId}/rules`); return apiGet(`/api/projects/${projectId}/rules`);
} }
export async function createProjectRule(
projectId: number,
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
): Promise<Rule> {
return apiPost(`/api/projects/${projectId}/rules`, data);
}
// ── Suppressions ───────────────────────────────────────────────────
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {});
}
export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`);
}
export async function suppressTopicForProject(projectId: number, topicId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {});
}
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
}
+3 -5
View File
@@ -39,14 +39,13 @@ router.afterEach(() => {
<!-- Left: brand --> <!-- Left: brand -->
<router-link to="/" class="nav-brand"> <router-link to="/" class="nav-brand">
<AppLogo :size="34" /> <AppLogo :size="34" />
<span class="brand-text">Scribe</span> <span class="brand-text">Fabled</span>
</router-link> </router-link>
<!-- Center: primary navigation (desktop) --> <!-- Center: primary navigation (desktop) -->
<div class="nav-center"> <div class="nav-center">
<div class="nav-pill-bar"> <div class="nav-pill-bar">
<router-link to="/dashboard" class="nav-link">Dashboard</router-link> <router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link> <router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link> <router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link> <router-link to="/rules" class="nav-link">Rulebooks</router-link>
@@ -91,8 +90,7 @@ router.afterEach(() => {
<!-- Mobile dropdown --> <!-- Mobile dropdown -->
<div v-if="mobileMenuOpen" class="mobile-menu"> <div v-if="mobileMenuOpen" class="mobile-menu">
<router-link to="/dashboard" class="nav-link">Dashboard</router-link> <router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link> <router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link> <router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link> <router-link to="/rules" class="nav-link">Rulebooks</router-link>
+5 -18
View File
@@ -13,23 +13,6 @@ const typeIcon: Record<string, string> = {
project_shared: '📁', project_shared: '📁',
note_shared: '📝', note_shared: '📝',
group_added: '👥', group_added: '👥',
event_reminder: '⏰',
}
function notifMessage(n: { type: string; payload: Record<string, unknown> }): string {
const p = n.payload
switch (n.type) {
case 'project_shared':
return ` shared "${p.project_title}" with you as ${p.permission}`
case 'note_shared':
return ` shared "${p.note_title}" with you as ${p.permission}`
case 'group_added':
return ` added you to "${p.group_name}" as ${p.role}`
case 'event_reminder':
return `Reminder: "${p.title}" is coming up`
default:
return ''
}
} }
async function handleClick(notif: { id: number; payload: Record<string, unknown> }) { async function handleClick(notif: { id: number; payload: Record<string, unknown> }) {
@@ -66,7 +49,11 @@ onMounted(() => store.fetchAll())
<div class="notif-body"> <div class="notif-body">
<p class="notif-msg"> <p class="notif-msg">
<strong v-if="n.payload.invited_by">{{ n.payload.invited_by }}</strong> <strong v-if="n.payload.invited_by">{{ n.payload.invited_by }}</strong>
{{ notifMessage(n) }} {{ n.type === 'project_shared'
? ` shared "${n.payload.project_title}" with you as ${n.payload.permission}`
: n.type === 'note_shared'
? ` shared "${n.payload.note_title}" with you as ${n.payload.permission}`
: ` added you to "${n.payload.group_name}" as ${n.payload.role}` }}
</p> </p>
<span class="notif-time">{{ relativeTime(n.created_at) }}</span> <span class="notif-time">{{ relativeTime(n.created_at) }}</span>
</div> </div>
+19 -246
View File
@@ -3,9 +3,7 @@ import { ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { import {
getProjectApplicableRules, subscribeProject, unsubscribeProject, getProjectApplicableRules, subscribeProject, unsubscribeProject,
listRulebooks, getRule, createProjectRule, deleteRule, listRulebooks, getRule,
suppressRuleForProject, unsuppressRuleForProject,
suppressTopicForProject, unsuppressTopicForProject,
} from "@/api/rulebooks"; } from "@/api/rulebooks";
import type { ApplicableRules, Rulebook } from "@/api/rulebooks"; import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
@@ -18,9 +16,6 @@ const expandedRuleIds = ref<Set<number>>(new Set());
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({}); const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
const showProjectRuleForm = ref(false);
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" });
async function load() { async function load() {
applicable.value = await getProjectApplicableRules(props.projectId); applicable.value = await getProjectApplicableRules(props.projectId);
} }
@@ -64,74 +59,18 @@ function openInRulesView(rulebookId: number, ruleId?: number) {
router.push({ path: "/rules", query }); router.push({ path: "/rules", query });
} }
interface TopicGroup { function groupByRulebookAndTopic(rules: ApplicableRules["rules"]) {
topic_id: number; const grouped: Record<string, Record<string, ApplicableRules["rules"]>> = {};
topic_title: string;
rules: ApplicableRules["rules"];
}
interface RulebookGroup {
rulebook_id: number;
rulebook_title: string;
topics: TopicGroup[];
}
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
const byRulebook = new Map<number, RulebookGroup>();
for (const r of rules) { for (const r of rules) {
let rb = byRulebook.get(r.rulebook_id); if (!grouped[r.rulebook_title]) grouped[r.rulebook_title] = {};
if (!rb) { if (!grouped[r.rulebook_title][r.topic_title]) grouped[r.rulebook_title][r.topic_title] = [];
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] }; grouped[r.rulebook_title][r.topic_title].push(r);
byRulebook.set(r.rulebook_id, rb);
}
let topic = rb.topics.find((t) => t.topic_id === r.topic_id);
if (!topic) {
topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] };
rb.topics.push(topic);
}
topic.rules.push(r);
} }
return Array.from(byRulebook.values()); return grouped;
} }
async function submitProjectRule() { function rulebookIdForTitle(title: string): number | undefined {
const statement = newProjectRule.value.statement.trim(); return applicable.value?.subscribed_rulebooks.find((rb) => rb.title === title)?.id;
if (!statement) return;
await createProjectRule(props.projectId, {
statement,
title: newProjectRule.value.title.trim() || undefined,
why: newProjectRule.value.why.trim() || undefined,
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
});
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" };
showProjectRuleForm.value = false;
await load();
}
async function removeProjectRule(ruleId: number) {
if (!confirm("Delete this project rule? It will move to the trash.")) return;
await deleteRule(ruleId);
await load();
}
const showSuppressed = ref(false);
async function suppressRule(ruleId: number) {
await suppressRuleForProject(props.projectId, ruleId);
await load();
}
async function unsuppressRule(ruleId: number) {
await unsuppressRuleForProject(props.projectId, ruleId);
await load();
}
async function suppressTopic(topicId: number) {
await suppressTopicForProject(props.projectId, topicId);
await load();
}
async function unsuppressTopic(topicId: number) {
await unsuppressTopicForProject(props.projectId, topicId);
await load();
} }
onMounted(async () => { onMounted(async () => {
@@ -172,69 +111,6 @@ watch(() => props.projectId, load);
</div> </div>
</section> </section>
<section class="project-rules">
<div class="section-head">
<h3>Project rules</h3>
<button
v-if="!showProjectRuleForm"
class="add"
@click="showProjectRuleForm = true"
>
+ New project rule
</button>
</div>
<form v-if="showProjectRuleForm" class="new-rule-form" @submit.prevent="submitProjectRule">
<input
v-model="newProjectRule.title"
placeholder="Title (optional — derived from statement if blank)"
/>
<textarea
v-model="newProjectRule.statement"
required
autofocus
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
rows="2"
></textarea>
<textarea
v-model="newProjectRule.why"
placeholder="Why (optional) — the rationale"
rows="2"
></textarea>
<textarea
v-model="newProjectRule.how_to_apply"
placeholder="How to apply (optional) — when / where it kicks in"
rows="2"
></textarea>
<div class="form-buttons">
<button type="submit">Create</button>
<button type="button" @click="showProjectRuleForm = false">Cancel</button>
</div>
</form>
<ul v-if="applicable.project_rules && applicable.project_rules.length > 0" class="rule-list">
<li v-for="r in applicable.project_rules" :key="r.id" class="rule">
<div class="rule-head" @click="toggleRuleExpand(r.id)">
<span class="rule-title">{{ r.title }}</span>
<span class="rule-statement">{{ r.statement }}</span>
</div>
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
<div v-if="ruleDetails[r.id].why">
<strong>Why:</strong> {{ ruleDetails[r.id].why }}
</div>
<div v-if="ruleDetails[r.id].how_to_apply">
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
</div>
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
</div>
</li>
</ul>
<p
v-else-if="!showProjectRuleForm"
class="empty"
>
No project-only rules yet.
</p>
</section>
<section class="applicable"> <section class="applicable">
<h3>Applicable rules</h3> <h3>Applicable rules</h3>
<p v-if="applicable.rules.length === 0" class="empty"> <p v-if="applicable.rules.length === 0" class="empty">
@@ -242,32 +118,18 @@ watch(() => props.projectId, load);
<a @click="router.push('/rules')">Rulebooks</a>. <a @click="router.push('/rules')">Rulebooks</a>.
</p> </p>
<div <div
v-for="rb in groupByRulebookAndTopic(applicable.rules)" v-for="(topics, rbTitle) in groupByRulebookAndTopic(applicable.rules)"
:key="rb.rulebook_id" :key="rbTitle"
class="rb-group" class="rb-group"
> >
<h4>{{ rb.rulebook_title }}</h4> <h4>{{ rbTitle }}</h4>
<div v-for="topic in rb.topics" :key="topic.topic_id" class="topic-group"> <div v-for="(rules, topicTitle) in topics" :key="topicTitle" class="topic-group">
<h5> <h5>{{ topicTitle }}</h5>
<span>{{ topic.topic_title }}</span>
<button
class="skip-btn"
:title="`Skip the entire ${topic.topic_title} topic for this project`"
@click="suppressTopic(topic.topic_id)"
>× skip topic</button>
</h5>
<ul> <ul>
<li v-for="r in topic.rules" :key="r.id" class="rule"> <li v-for="r in rules" :key="r.id" class="rule">
<div class="rule-head"> <div class="rule-head" @click="toggleRuleExpand(r.id)">
<div class="rule-head-text" @click="toggleRuleExpand(r.id)"> <span class="rule-title">{{ r.title }}</span>
<span class="rule-title">{{ r.title }}</span> <span class="rule-statement">{{ r.statement }}</span>
<span class="rule-statement">{{ r.statement }}</span>
</div>
<button
class="skip-btn"
title="Skip this rule for this project"
@click.stop="suppressRule(r.id)"
>× skip</button>
</div> </div>
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail"> <div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
<div v-if="ruleDetails[r.id].why"> <div v-if="ruleDetails[r.id].why">
@@ -278,7 +140,7 @@ watch(() => props.projectId, load);
</div> </div>
<button <button
class="edit-link" class="edit-link"
@click="openInRulesView(r.rulebook_id, r.id)" @click="rulebookIdForTitle(String(rbTitle)) && openInRulesView(rulebookIdForTitle(String(rbTitle))!, r.id)"
> >
Edit in Rulebook Edit in Rulebook
</button> </button>
@@ -291,32 +153,6 @@ watch(() => props.projectId, load);
Truncated at 50 rules there are more applicable rules. Truncated at 50 rules there are more applicable rules.
</p> </p>
</section> </section>
<section
v-if="applicable.suppressed_rules.length + applicable.suppressed_topics.length > 0"
class="suppressed"
>
<button class="suppressed-toggle" @click="showSuppressed = !showSuppressed">
<span>Suppressed ({{ applicable.suppressed_rules.length + applicable.suppressed_topics.length }})</span>
<span class="caret">{{ showSuppressed ? "▾" : "▸" }}</span>
</button>
<div v-if="showSuppressed" class="suppressed-body">
<ul v-if="applicable.suppressed_topics.length > 0" class="suppressed-list">
<li v-for="t in applicable.suppressed_topics" :key="`topic-${t.id}`">
<span class="suppressed-kind">topic</span>
<span class="suppressed-path">{{ t.rulebook_title }} {{ t.title }}</span>
<button class="reenable-btn" @click="unsuppressTopic(t.id)"> re-enable</button>
</li>
</ul>
<ul v-if="applicable.suppressed_rules.length > 0" class="suppressed-list">
<li v-for="r in applicable.suppressed_rules" :key="`rule-${r.id}`">
<span class="suppressed-kind">rule</span>
<span class="suppressed-path">{{ r.rulebook_title }} {{ r.topic_title }} {{ r.title }}</span>
<button class="reenable-btn" @click="unsuppressRule(r.id)"> re-enable</button>
</li>
</ul>
</div>
</section>
</div> </div>
</template> </template>
@@ -372,67 +208,4 @@ ul { list-style: none; padding: 0; margin: 0; }
} }
.empty, .truncated { opacity: 0.7; font-style: italic; } .empty, .truncated { opacity: 0.7; font-style: italic; }
.empty a { cursor: pointer; text-decoration: underline; } .empty a { cursor: pointer; text-decoration: underline; }
.project-rules { margin-top: 1.5rem; }
.section-head { display: flex; justify-content: space-between; align-items: center; }
.new-rule-form {
display: flex; flex-direction: column; gap: 0.5rem;
padding: 0.75rem; margin: 0.5rem 0;
background: var(--color-bg, #111113);
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
}
.new-rule-form input, .new-rule-form textarea {
background: var(--color-surface, #18181b); color: inherit;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
padding: 0.5rem; font: inherit; resize: vertical;
}
.rule-list { margin-top: 0.5rem; }
.delete-link {
background: none; border: none; cursor: pointer;
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
}
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
.topic-group h5 {
display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;
}
.rule-head {
display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;
}
.rule-head-text { flex: 1; cursor: pointer; }
.skip-btn {
background: none; border: none; cursor: pointer;
color: var(--color-muted, #888); font-size: 0.75rem;
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
white-space: nowrap;
}
.topic-group h5:hover .skip-btn,
.rule:hover .skip-btn,
.skip-btn:focus { opacity: 1; }
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
/* Suppressed section */
.suppressed { margin-top: 1.5rem; }
.suppressed-toggle {
display: flex; align-items: center; gap: 0.4rem;
background: none; border: none; cursor: pointer;
font-size: 0.85rem; opacity: 0.7; padding: 0.25rem 0; color: inherit;
}
.suppressed-toggle:hover { opacity: 1; }
.suppressed-toggle .caret { font-size: 0.7em; }
.suppressed-body { margin-top: 0.5rem; }
.suppressed-list { padding-left: 0; }
.suppressed-list li {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.25rem 0; opacity: 0.75;
}
.suppressed-kind {
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.4rem; border-radius: 3px;
background: var(--color-bg, #111113);
border: 1px solid var(--color-border, #2a2a2e);
}
.suppressed-path { flex: 1; }
.reenable-btn {
background: none; border: none; cursor: pointer;
color: var(--color-primary, #6366f1); font-size: 0.85em;
}
.reenable-btn:hover { text-decoration: underline; }
</style> </style>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"; import { ref, onMounted, watch } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks"; import { useRulebooksStore } from "@/stores/rulebooks";
import { apiGet } from "@/api/client"; import { apiGet } from "@/api/client";
import { import {
@@ -18,10 +18,6 @@ const store = useRulebooksStore();
const isCreating = ref(false); const isCreating = ref(false);
const newTitle = ref(""); const newTitle = ref("");
const currentRulebook = computed(() =>
store.rulebooks.find((rb) => rb.id === props.rulebookId),
);
interface ProjectLite { id: number; title: string } interface ProjectLite { id: number; title: string }
const projects = ref<ProjectLite[]>([]); const projects = ref<ProjectLite[]>([]);
// Map<project_id, Set<rulebook_id>> // Map<project_id, Set<rulebook_id>>
@@ -72,17 +68,7 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
<template> <template>
<section class="pane"> <section class="pane">
<header> <header><h2>Topics</h2></header>
<h2>Topics</h2>
<label v-if="currentRulebook" class="always-on-toggle" title="When on, rules from this rulebook load at session start regardless of project context">
<input
type="checkbox"
:checked="currentRulebook.always_on"
@change="store.toggleAlwaysOn(currentRulebook.id)"
/>
<span>Always on</span>
</label>
</header>
<ul> <ul>
<li <li
v-for="t in topics" v-for="t in topics"
@@ -123,14 +109,7 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
<style scoped> <style scoped>
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; } .pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; } header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
.always-on-toggle {
display: flex; align-items: center; gap: 0.4rem;
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
user-select: none;
}
.always-on-toggle input { cursor: pointer; }
ul { list-style: none; padding: 0; margin: 1rem 0; } ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; } li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); } li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
@@ -31,7 +31,6 @@ async function submitNew() {
@click="emit('select', rb.id)" @click="emit('select', rb.id)"
> >
<span class="title">{{ rb.title }}</span> <span class="title">{{ rb.title }}</span>
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
</li> </li>
</ul> </ul>
<div class="new-rulebook"> <div class="new-rulebook">
@@ -51,19 +50,9 @@ async function submitNew() {
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; } .pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; } header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
ul { list-style: none; padding: 0; margin: 1rem 0; } ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; } li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); } li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); } li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
.always-on-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: var(--color-accent, rgba(91,74,138,0.25));
color: var(--color-accent-fg, inherit);
margin-left: auto;
}
.new-rulebook { margin-top: 1rem; } .new-rulebook { margin-top: 1rem; }
.new-rulebook input { .new-rulebook input {
width: 100%; margin-bottom: 0.5rem; width: 100%; margin-bottom: 0.5rem;
+3 -8
View File
@@ -5,15 +5,10 @@ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ {
// The dashboard ("what to work on") is the landing page; Knowledge // Knowledge is the landing page in the MCP-first architecture
// remains as the exhaustive "Browse" surface. // (chat / journal / workspace surfaces have been removed).
path: "/", path: "/",
redirect: "/dashboard", redirect: "/knowledge",
},
{
path: "/dashboard",
name: "dashboard",
component: () => import("@/views/DashboardView.vue"),
}, },
{ {
path: "/knowledge", path: "/knowledge",
+117 -5
View File
@@ -2,16 +2,66 @@ import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client"; import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import type { Note } from "@/types/note"; import type { Note, NoteListResponse } from "@/types/note";
// Single-note + mutation surface. The list/filter/sort/pagination surface that
// backed the removed /notes list view was dropped in the 2026-06-02 drift-audit
// cleanup (no consumers — KnowledgeView and the editors use single-entity and
// mutation methods only).
export const useNotesStore = defineStore("notes", () => { export const useNotesStore = defineStore("notes", () => {
const notes = ref<Note[]>([]);
const currentNote = ref<Note | null>(null); const currentNote = ref<Note | null>(null);
const total = ref(0);
const loading = ref(false); const loading = ref(false);
// Filter / pagination / sort state
const activeTagFilters = ref<string[]>([]);
const limit = ref(20);
const offset = ref(0);
const sortField = ref("updated_at");
const sortOrder = ref<"asc" | "desc">("desc");
const searchQuery = ref("");
async function refresh() {
loading.value = true;
try {
const searchParams = new URLSearchParams();
if (searchQuery.value) searchParams.set("q", searchQuery.value);
for (const t of activeTagFilters.value) {
searchParams.append("tag", t);
}
searchParams.set("sort", sortField.value);
searchParams.set("order", sortOrder.value);
searchParams.set("limit", String(limit.value));
searchParams.set("offset", String(offset.value));
const qs = searchParams.toString();
const data = await apiGet<NoteListResponse>(
`/api/notes${qs ? `?${qs}` : ""}`
);
notes.value = data.notes;
total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load notes", "error");
throw e;
} finally {
loading.value = false;
}
}
async function fetchNotes(params?: {
q?: string;
tag?: string[];
sort?: string;
order?: string;
limit?: number;
offset?: number;
}) {
if (params?.q !== undefined) searchQuery.value = params.q || "";
if (params?.tag) activeTagFilters.value = params.tag;
if (params?.sort) sortField.value = params.sort;
if (params?.order) sortOrder.value = params.order as "asc" | "desc";
if (params?.limit) limit.value = params.limit;
if (params?.offset !== undefined) offset.value = params.offset;
await refresh();
}
async function fetchNote(id: number) { async function fetchNote(id: number) {
loading.value = true; loading.value = true;
try { try {
@@ -60,6 +110,7 @@ export const useNotesStore = defineStore("notes", () => {
async function deleteNote(id: number) { async function deleteNote(id: number) {
try { try {
await apiDelete(`/api/notes/${id}`); await apiDelete(`/api/notes/${id}`);
notes.value = notes.value.filter((n) => n.id !== id);
if (currentNote.value?.id === id) { if (currentNote.value?.id === id) {
currentNote.value = null; currentNote.value = null;
} }
@@ -69,6 +120,50 @@ export const useNotesStore = defineStore("notes", () => {
} }
} }
function addTagFilter(tag: string) {
if (!activeTagFilters.value.includes(tag)) {
activeTagFilters.value.push(tag);
offset.value = 0;
refresh();
}
}
function removeTagFilter(tag: string) {
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
offset.value = 0;
refresh();
}
function clearTagFilters() {
activeTagFilters.value = [];
offset.value = 0;
refresh();
}
function setTagFilters(tags: string[]) {
activeTagFilters.value = [...tags];
offset.value = 0;
refresh();
}
function setSort(field: string, order: "asc" | "desc") {
sortField.value = field;
sortOrder.value = order;
offset.value = 0;
refresh();
}
function setOffset(newOffset: number) {
offset.value = newOffset;
refresh();
}
function setSearch(q: string) {
searchQuery.value = q;
offset.value = 0;
refresh();
}
async function resolveTitle(title: string): Promise<Note> { async function resolveTitle(title: string): Promise<Note> {
return await apiPost<Note>("/api/notes/resolve-title", { title }); return await apiPost<Note>("/api/notes/resolve-title", { title });
} }
@@ -121,12 +216,29 @@ export const useNotesStore = defineStore("notes", () => {
} }
return { return {
notes,
currentNote, currentNote,
total,
loading, loading,
activeTagFilters,
limit,
offset,
sortField,
sortOrder,
searchQuery,
fetchNotes,
fetchNote, fetchNote,
createNote, createNote,
updateNote, updateNote,
deleteNote, deleteNote,
addTagFilter,
removeTagFilter,
clearTagFilters,
setTagFilters,
setSort,
setOffset,
setSearch,
refresh,
resolveTitle, resolveTitle,
convertToTask, convertToTask,
convertToNote, convertToNote,
+2 -8
View File
@@ -54,19 +54,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
return rb; return rb;
} }
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description" | "always_on">>) { async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description">>) {
const rb = await api.updateRulebook(id, data); const rb = await api.updateRulebook(id, data);
const idx = rulebooks.value.findIndex((r) => r.id === id); const idx = rulebooks.value.findIndex((r) => r.id === id);
if (idx >= 0) rulebooks.value[idx] = rb; if (idx >= 0) rulebooks.value[idx] = rb;
return rb; return rb;
} }
async function toggleAlwaysOn(id: number) {
const current = rulebooks.value.find((r) => r.id === id);
if (!current) return;
return updateRulebook(id, { always_on: !current.always_on });
}
async function deleteRulebook(id: number) { async function deleteRulebook(id: number) {
await api.deleteRulebook(id); await api.deleteRulebook(id);
rulebooks.value = rulebooks.value.filter((r) => r.id !== id); rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
@@ -127,7 +121,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
return { return {
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading, rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
fetchRulebooks, fetchTopics, fetchRules, fetchRule, fetchRulebooks, fetchTopics, fetchRules, fetchRule,
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook, createRulebook, updateRulebook, deleteRulebook,
createTopic, updateTopic, deleteTopic, createTopic, updateTopic, deleteTopic,
createRule, updateRule, deleteRule, createRule, updateRule, deleteRule,
}; };
+116 -4
View File
@@ -2,15 +2,53 @@ import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client"; import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import type { Task, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task"; import type { Task, TaskListResponse, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
// Single-task + mutation surface. The list/filter/sort/pagination surface that
// backed the removed /tasks list view was dropped in the 2026-06-02 drift-audit
// cleanup (no consumers — ProjectView's kanban keeps its own local task list).
export const useTasksStore = defineStore("tasks", () => { export const useTasksStore = defineStore("tasks", () => {
const tasks = ref<Task[]>([]);
const currentTask = ref<Task | null>(null); const currentTask = ref<Task | null>(null);
const total = ref(0);
const loading = ref(false); const loading = ref(false);
// Filter / pagination / sort state
const activeTagFilters = ref<string[]>([]);
const statusFilter = ref<TaskStatus[]>([]);
const priorityFilter = ref<TaskPriority[]>([]);
const limit = ref(20);
const offset = ref(0);
const sortField = ref("updated_at");
const sortOrder = ref<"asc" | "desc">("desc");
const searchQuery = ref("");
async function refresh() {
loading.value = true;
try {
const searchParams = new URLSearchParams();
if (searchQuery.value) searchParams.set("q", searchQuery.value);
for (const t of activeTagFilters.value) {
searchParams.append("tag", t);
}
for (const s of statusFilter.value) searchParams.append("status", s);
for (const p of priorityFilter.value) searchParams.append("priority", p);
searchParams.set("sort", sortField.value);
searchParams.set("order", sortOrder.value);
searchParams.set("limit", String(limit.value));
searchParams.set("offset", String(offset.value));
const qs = searchParams.toString();
const data = await apiGet<TaskListResponse>(
`/api/tasks${qs ? `?${qs}` : ""}`
);
tasks.value = data.tasks;
total.value = data.total;
} catch (e) {
useToastStore().show("Failed to load tasks", "error");
throw e;
} finally {
loading.value = false;
}
}
async function fetchTask(id: number) { async function fetchTask(id: number) {
loading.value = true; loading.value = true;
try { try {
@@ -64,6 +102,10 @@ export const useTasksStore = defineStore("tasks", () => {
async function patchStatus(id: number, status: TaskStatus): Promise<Task> { async function patchStatus(id: number, status: TaskStatus): Promise<Task> {
try { try {
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status }); const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
const idx = tasks.value.findIndex((t) => t.id === id);
if (idx !== -1) {
tasks.value[idx] = task;
}
if (currentTask.value?.id === id) { if (currentTask.value?.id === id) {
currentTask.value = task; currentTask.value = task;
} }
@@ -77,6 +119,7 @@ export const useTasksStore = defineStore("tasks", () => {
async function deleteTask(id: number) { async function deleteTask(id: number) {
try { try {
await apiDelete(`/api/tasks/${id}`); await apiDelete(`/api/tasks/${id}`);
tasks.value = tasks.value.filter((t) => t.id !== id);
if (currentTask.value?.id === id) { if (currentTask.value?.id === id) {
currentTask.value = null; currentTask.value = null;
} }
@@ -101,14 +144,83 @@ export const useTasksStore = defineStore("tasks", () => {
} }
} }
function setStatusFilter(statuses: TaskStatus[]) {
statusFilter.value = statuses;
offset.value = 0;
refresh();
}
function setPriorityFilter(priorities: TaskPriority[]) {
priorityFilter.value = priorities;
offset.value = 0;
refresh();
}
function addTagFilter(tag: string) {
if (!activeTagFilters.value.includes(tag)) {
activeTagFilters.value.push(tag);
offset.value = 0;
refresh();
}
}
function removeTagFilter(tag: string) {
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
offset.value = 0;
refresh();
}
function clearTagFilters() {
activeTagFilters.value = [];
offset.value = 0;
refresh();
}
function setSort(field: string, order: "asc" | "desc") {
sortField.value = field;
sortOrder.value = order;
offset.value = 0;
refresh();
}
function setOffset(newOffset: number) {
offset.value = newOffset;
refresh();
}
function setSearch(q: string) {
searchQuery.value = q;
offset.value = 0;
refresh();
}
return { return {
tasks,
currentTask, currentTask,
total,
loading, loading,
activeTagFilters,
statusFilter,
priorityFilter,
limit,
offset,
sortField,
sortOrder,
searchQuery,
refresh,
fetchTask, fetchTask,
createTask, createTask,
updateTask, updateTask,
patchStatus, patchStatus,
deleteTask, deleteTask,
startPlanning, startPlanning,
setStatusFilter,
setPriorityFilter,
addTagFilter,
removeTagFilter,
clearTagFilters,
setSort,
setOffset,
setSearch,
}; };
}); });
+67
View File
@@ -0,0 +1,67 @@
export interface GenerationTiming {
total_ms: number;
intent_ms: number | null;
ttft_ms: number | null;
generation_ms: number | null;
tools: Array<{ name: string; ms: number }>;
}
export interface ToolCallRecord {
function: string;
arguments: Record<string, unknown>;
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[]; requires_confirmation?: boolean; similar_note?: { id: number; title: string } };
status: "running" | "success" | "error" | "declined";
}
export interface ToolPendingRecord {
function: string;
arguments: Record<string, unknown>;
label?: string;
}
export interface Message {
id: number;
conversation_id: number;
role: "system" | "user" | "assistant";
content: string;
status?: "complete" | "generating" | "error";
context_note_id: number | null;
context_note_title?: string | null;
tool_calls?: ToolCallRecord[] | null;
metadata?: Record<string, unknown> | null;
created_at: string;
timing?: GenerationTiming;
thinking?: string;
}
export interface SendMessageResponse {
assistant_message_id: number;
status: string;
}
export interface Conversation {
id: number;
title: string;
model: string;
message_count: number;
rag_project_id: number | null;
created_at: string;
updated_at: string;
}
export interface ConversationDetail extends Conversation {
messages: Message[];
}
export interface ContextMeta {
context_note_id: number | null;
context_note_title: string | null;
auto_notes: { id: number; title: string; score?: number | null; auto_injected?: boolean }[];
auto_injected_notes?: { id: number; title: string; score?: number | null }[];
}
export interface OllamaStatus {
ollama: "available" | "unavailable";
model: "loaded" | "cold" | "not_found";
default_model: string;
}
+1 -1
View File
@@ -1,6 +1,6 @@
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled"; export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
export type TaskPriority = "none" | "low" | "medium" | "high"; export type TaskPriority = "none" | "low" | "medium" | "high";
export type NoteType = "note" | "person" | "place" | "list" | "process"; export type NoteType = "note" | "person" | "place" | "list";
export interface Note { export interface Note {
id: number; id: number;
+2 -3
View File
@@ -1,6 +1,5 @@
// Settings are free-form string key/value pairs keyed by setting name; the
// backend has no fixed schema, so this is an open string map. (The former
// assistant_name/default_model hints were dead after the Phase-8 chat removal.)
export interface AppSettings { export interface AppSettings {
assistant_name?: string;
default_model?: string;
[key: string]: string | undefined; [key: string]: string | undefined;
} }
+2 -2
View File
@@ -168,11 +168,11 @@ function onCalendarChanged() {
onMounted(() => { onMounted(() => {
document.addEventListener("mousedown", onDocClick); document.addEventListener("mousedown", onDocClick);
document.addEventListener("scribe:calendar-changed", onCalendarChanged); document.addEventListener("fable:calendar-changed", onCalendarChanged);
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("mousedown", onDocClick); document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("scribe:calendar-changed", onCalendarChanged); document.removeEventListener("fable:calendar-changed", onCalendarChanged);
}); });
// ── Calendar callbacks ───────────────────────────────────────────────────── // ── Calendar callbacks ─────────────────────────────────────────────────────
-208
View File
@@ -1,208 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet } from "@/api/client";
import { relativeTime } from "@/composables/useRelativeTime";
interface TaskRow { id: number; title: string; status: string; priority: string }
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
interface ActiveProject {
id: number; title: string; color: string | null; last_activity: string;
open_count: number; progress_pct: number;
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
}
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
interface DashboardData {
active_projects: ActiveProject[];
recently_completed: DoneItem[];
upcoming_events: UpcomingEvent[];
week_stats: WeekStats;
}
const data = ref<DashboardData | null>(null);
const loading = ref(true);
onMounted(async () => {
try {
data.value = await apiGet<DashboardData>("/api/dashboard");
} catch {
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
} finally {
loading.value = false;
}
});
function fmtEvent(e: UpcomingEvent): string {
if (!e.start_dt) return "";
const d = new Date(e.start_dt);
const day = d.toLocaleDateString(undefined, { weekday: "short" });
if (e.all_day) return day;
return `${day} ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
}
</script>
<template>
<div class="dash-root">
<header class="dash-head">
<h1>Dashboard</h1>
<p class="dash-sub">What to work on, across your most-active projects.</p>
</header>
<div v-if="loading" class="dash-empty">Loading</div>
<template v-else-if="data">
<!-- Done recently -->
<section v-if="data.recently_completed.length" class="done-strip">
<span class="dash-label"> Done recently</span>
<router-link
v-for="d in data.recently_completed"
:key="d.id"
:to="`/tasks/${d.id}`"
class="done-chip"
>
{{ d.title }}
<span class="done-meta">{{ d.project_title || "—" }} · {{ relativeTime(d.completed_at) }}</span>
</router-link>
</section>
<div class="dash-cols">
<!-- Active now -->
<main class="dash-main">
<div class="dash-label">Active now</div>
<div v-if="!data.active_projects.length" class="dash-empty card">
No active projects yet <router-link to="/projects">create one</router-link>.
</div>
<article v-for="p in data.active_projects" :key="p.id" class="proj-panel">
<header class="proj-head">
<span class="proj-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
<router-link :to="`/projects/${p.id}`" class="proj-title">{{ p.title }}</router-link>
<span class="proj-meta">{{ relativeTime(p.last_activity) }} · {{ p.open_count }} open</span>
</header>
<div class="bar"><div class="bar-fill" :style="{ width: p.progress_pct + '%' }" /></div>
<div v-for="m in p.milestones" :key="m.id" class="ms-block">
<div class="ms-head">
<span class="ms-title">{{ m.title }}</span>
<span class="ms-pct">{{ m.progress_pct }}%</span>
</div>
<router-link
v-for="t in m.open_tasks"
:key="t.id"
:to="`/tasks/${t.id}`"
class="task-row"
:class="{ 'task-inprogress': t.status === 'in_progress' }"
>
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="task-title">{{ t.title }}</span>
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
</router-link>
</div>
<div v-if="p.no_milestone.length" class="ms-block">
<div class="ms-head"><span class="ms-title ms-none">No milestone</span></div>
<router-link
v-for="t in p.no_milestone"
:key="t.id"
:to="`/tasks/${t.id}`"
class="task-row"
:class="{ 'task-inprogress': t.status === 'in_progress' }"
>
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="task-title">{{ t.title }}</span>
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
</router-link>
</div>
<router-link :to="`/projects/${p.id}`" class="proj-more">+ more in {{ p.title }} </router-link>
</article>
</main>
<!-- Right rail -->
<aside class="dash-rail">
<div class="dash-label">Upcoming · 7 days</div>
<div class="rail-card">
<template v-if="data.upcoming_events.length">
<div v-for="e in data.upcoming_events" :key="e.id" class="evt-row">
<span class="evt-when">{{ fmtEvent(e) }}</span>
<span class="evt-title">{{ e.title }}</span>
</div>
</template>
<p v-else class="rail-empty">Nothing scheduled.</p>
</div>
<div class="dash-label">This week</div>
<div class="rail-card stats">
<span> {{ data.week_stats.completed_this_week }} done</span>
<span> {{ data.week_stats.open_total }} open</span>
<span class="stats-sub">{{ data.week_stats.in_progress }} in progress · {{ data.week_stats.active_plans }} plans</span>
</div>
<div class="quick-add">
<router-link to="/tasks/new" class="qa-btn">+ Task</router-link>
<router-link to="/notes/new" class="qa-btn">+ Note</router-link>
<router-link to="/notes/new?type=process" class="qa-btn">+ Process</router-link>
</div>
</aside>
</div>
</template>
</div>
</template>
<style scoped>
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
.done-strip { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1.5rem; }
.done-chip { display: inline-flex; flex-direction: column; gap: 1px; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 14px; padding: 4px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
.done-chip:hover { border-color: var(--color-primary); }
.done-meta { font-size: 0.7rem; color: var(--color-muted); }
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
.dash-main { flex: 1.7; min-width: 0; }
.dash-rail { flex: 1; min-width: 240px; }
.proj-panel { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
.proj-head { display: flex; align-items: center; gap: 0.5rem; }
.proj-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.proj-title { font-weight: 700; color: var(--color-text); text-decoration: none; }
.proj-title:hover { color: var(--color-primary); }
.proj-meta { margin-left: auto; font-size: 0.74rem; color: var(--color-muted); }
.bar { height: 5px; background: var(--color-border); border-radius: 3px; margin: 0.55rem 0 0.2rem; }
.bar-fill { height: 5px; background: var(--color-primary); border-radius: 3px; }
.ms-block { margin-top: 0.7rem; }
.ms-head { display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.3rem; }
.ms-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); }
.ms-title.ms-none { color: var(--color-muted); font-weight: 500; }
.ms-pct { margin-left: auto; font-size: 0.72rem; color: var(--color-muted); }
.task-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
.task-row:hover { background: var(--color-hover); }
.task-inprogress { border-left: 3px solid var(--color-primary); padding-left: calc(0.55rem - 3px); }
.task-mark { color: var(--color-muted); }
.task-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-pri { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 6px; border-radius: 8px; border: 1px solid var(--color-border); color: var(--color-muted); }
.pri-high { color: #c0556b; border-color: #c0556b66; }
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
.evt-row { display: flex; gap: 0.6rem; padding: 0.3rem 0; border-bottom: 1px solid var(--color-border); font-size: 0.85rem; }
.evt-row:last-child { border-bottom: none; }
.evt-when { color: var(--color-muted); white-space: nowrap; }
.evt-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
</style>
+6 -12
View File
@@ -10,7 +10,6 @@ import {
User, User,
MapPin, MapPin,
List, List,
Workflow,
Search, Search,
Share2, Share2,
ChevronLeft, ChevronLeft,
@@ -24,7 +23,7 @@ const router = useRouter();
interface KnowledgeItem { interface KnowledgeItem {
id: number; id: number;
note_type: "note" | "person" | "place" | "list" | "task" | "process"; note_type: "note" | "person" | "place" | "list" | "task";
title: string; title: string;
snippet: string; snippet: string;
tags: string[]; tags: string[];
@@ -62,7 +61,7 @@ interface UpcomingEvent {
// ─── Filter state ───────────────────────────────────────────────────────────── // ─── Filter state ─────────────────────────────────────────────────────────────
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan" | "process">(""); const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan">("");
const activeTag = ref(""); const activeTag = ref("");
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified"); const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
const searchQuery = ref(""); const searchQuery = ref("");
@@ -70,8 +69,8 @@ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
// ─── Type counts ────────────────────────────────────────────────────────────── // ─── Type counts ──────────────────────────────────────────────────────────────
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; process: number; total: number } interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; total: number }
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, process: 0, total: 0 }); const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, total: 0 });
async function fetchCounts() { async function fetchCounts() {
try { try {
@@ -404,10 +403,6 @@ onUnmounted(() => {
<List :size="16" /> <List :size="16" />
List List
</button> </button>
<button @click="createNew('process')">
<Workflow :size="16" />
Process
</button>
</div> </div>
</div> </div>
@@ -422,11 +417,11 @@ onUnmounted(() => {
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span> <span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
</button> </button>
<button <button
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list'],['process','Processes','process']] as [string,string,string][])" v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
:key="val" :key="val"
class="filter-btn" class="filter-btn"
:class="{ active: activeType === val }" :class="{ active: activeType === val }"
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan' | 'process')" @click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan')"
> >
<span class="filter-btn-label">{{ label }}</span> <span class="filter-btn-label">{{ label }}</span>
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span> <span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
@@ -499,7 +494,6 @@ onUnmounted(() => {
<span v-else-if="item.note_type === 'person'">Person</span> <span v-else-if="item.note_type === 'person'">Person</span>
<span v-else-if="item.note_type === 'place'">Place</span> <span v-else-if="item.note_type === 'place'">Place</span>
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span> <span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
<span v-else-if="item.note_type === 'process'">Process</span>
<span v-else>List</span> <span v-else>List</span>
</span> </span>
-33
View File
@@ -130,7 +130,6 @@ const titlePlaceholder = computed(() => {
case 'person': return 'Name'; case 'person': return 'Name';
case 'place': return 'Place name'; case 'place': return 'Place name';
case 'list': return 'List title'; case 'list': return 'List title';
case 'process': return 'Process name';
default: return 'Title'; default: return 'Title';
} }
}); });
@@ -603,18 +602,6 @@ onUnmounted(() => assist.clearSelection());
</div> </div>
</template> </template>
<!-- Process (prompt) editor -->
<template v-else-if="noteType === 'process'">
<label class="ef-label">Prompt</label>
<textarea
class="prompt-editor"
:value="body"
@input="onBodyUpdate(($event.target as HTMLTextAreaElement).value)"
placeholder="The prompt to run when this process is fired (markdown). If it needs inputs, instruct Claude to ask up front."
spellcheck="false"
></textarea>
</template>
<!-- Generic note editor --> <!-- Generic note editor -->
<template v-else> <template v-else>
<div class="body-tabs-row"> <div class="body-tabs-row">
@@ -1051,26 +1038,6 @@ onUnmounted(() => assist.clearSelection());
font-size: 0.92rem; font-size: 0.92rem;
color: var(--color-primary); color: var(--color-primary);
} }
.prompt-editor {
width: 100%;
min-height: 60vh;
margin-top: 8px;
padding: 14px 16px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
/* Prompts are plain markdown — a code-style editor, not rich text. */
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-size: 0.88rem;
line-height: 1.55;
tab-size: 2;
resize: vertical;
outline: none;
}
.prompt-editor:focus {
border-color: var(--color-primary);
}
.ef-input { .ef-input {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
+1
View File
@@ -22,6 +22,7 @@ interface Project {
goal: string | null; goal: string | null;
status: "active" | "paused" | "completed" | "archived"; status: "active" | "paused" | "completed" | "archived";
color: string | null; color: string | null;
auto_summary: string | null;
permission?: string; permission?: string;
is_shared?: boolean; is_shared?: boolean;
created_at: string; created_at: string;
+1
View File
@@ -37,6 +37,7 @@ interface Project {
goal: string | null; goal: string | null;
status: "active" | "paused" | "completed" | "archived"; status: "active" | "paused" | "completed" | "archived";
color: string | null; color: string | null;
auto_summary: string | null;
permission?: string; permission?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
+43 -3
View File
@@ -14,10 +14,25 @@ const toastStore = useToastStore();
const userTimezone = ref(""); const userTimezone = ref("");
const savingTimezone = ref(false); const savingTimezone = ref(false);
const timezoneSaved = ref(false); const timezoneSaved = ref(false);
const autoConsolidateTasks = ref(true);
const savingAutoConsolidate = ref(false);
const trashRetentionDays = ref("90"); const trashRetentionDays = ref("90");
const savingRetention = ref(false); const savingRetention = ref(false);
const retentionSaved = ref(false); const retentionSaved = 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;
}
}
// think_enabled setting removed 2026-05-23. The chat+curator architecture // think_enabled setting removed 2026-05-23. The chat+curator architecture
// has tools=[] on the chat model; think on a no-tools conversational pass // has tools=[] on the chat model; think on a no-tools conversational pass
// is pure latency cost. See generation_task.py:run_generation comment for // is pure latency cost. See generation_task.py:run_generation comment for
@@ -388,6 +403,8 @@ onMounted(async () => {
const allSettings = await apiGet<Record<string, string>>("/api/settings"); const allSettings = await apiGet<Record<string, string>>("/api/settings");
userTimezone.value = allSettings.user_timezone ?? ""; userTimezone.value = allSettings.user_timezone ?? "";
trashRetentionDays.value = allSettings.trash_retention_days ?? "90"; trashRetentionDays.value = allSettings.trash_retention_days ?? "90";
// Default true if unset; explicit "false" disables auto-consolidation.
autoConsolidateTasks.value = (allSettings.auto_consolidate_tasks ?? "true") !== "false";
if (allSettings.notify_task_reminders !== undefined) { if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false"; notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
} }
@@ -510,7 +527,7 @@ async function exportData(scope: "user" | "full") {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = `scribe-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`; a.download = `fabledassistant-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
a.click(); a.click();
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
toastStore.show("Backup downloaded"); toastStore.show("Backup downloaded");
@@ -533,7 +550,7 @@ async function exportNotes(format: "markdown" | "json") {
const stamp = new Date().toISOString().slice(0, 10); const stamp = new Date().toISOString().slice(0, 10);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = `scribe-${stamp}.${ext}`; a.download = `fabledassistant-${stamp}.${ext}`;
a.click(); a.click();
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
toastStore.show("Export downloaded"); toastStore.show("Export downloaded");
@@ -975,6 +992,29 @@ function formatUserDate(iso: string): string {
<!-- General --> <!-- General -->
<div v-show="activeTab === 'general'" class="settings-grid"> <div v-show="activeTab === 'general'" class="settings-grid">
<!-- 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 --> <!-- Timezone -->
<section class="settings-section full-width"> <section class="settings-section full-width">
<h2>Timezone</h2> <h2>Timezone</h2>
@@ -3076,7 +3116,7 @@ function formatUserDate(iso: string): string {
.settings-empty { opacity: 0.5; margin-top: 1rem; } .settings-empty { opacity: 0.5; margin-top: 1rem; }
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; } .settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
/* Scribe MCP section */ /* Fable MCP section */
.mcp-status { opacity: 0.6; font-size: 0.9rem; } .mcp-status { opacity: 0.6; font-size: 0.9rem; }
.mcp-unavailable p { opacity: 0.7; } .mcp-unavailable p { opacity: 0.7; }
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; } .mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
+1 -1
View File
@@ -3,7 +3,7 @@ requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "scribe" name = "fabledassistant"
version = "0.1.0" version = "0.1.0"
description = "Self-hosted note-taking, project-management, and calendar app exposed to Claude via MCP" description = "Self-hosted note-taking, project-management, and calendar app exposed to Claude via MCP"
requires-python = ">=3.14" requires-python = ">=3.14"
+1 -1
View File
@@ -103,7 +103,7 @@ async def run(args) -> None:
print(f" - {r['title']}: {preview}{empty_flag}") print(f" - {r['title']}: {preview}{empty_flag}")
return return
from scribe.services import rulebooks as rb_service from fabledassistant.services import rulebooks as rb_service
existing = await rb_service.find_rulebook_by_title(args.user_id, args.rulebook_title) existing = await rb_service.find_rulebook_by_title(args.user_id, args.rulebook_title)
if existing is not None and not args.force: if existing is not None and not args.force:
@@ -5,30 +5,29 @@ from pathlib import Path
from quart import Quart, g, jsonify, make_response, request, send_from_directory from quart import Quart, g, jsonify, make_response, request, send_from_directory
from scribe.config import Config from fabledassistant.config import Config
from scribe.routes.admin import admin_bp from fabledassistant.routes.admin import admin_bp
from scribe.routes.api import api from fabledassistant.routes.api import api
from scribe.routes.auth import auth_bp from fabledassistant.routes.auth import auth_bp
from scribe.routes.export import export_bp from fabledassistant.routes.export import export_bp
from scribe.routes.notes import notes_bp from fabledassistant.routes.notes import notes_bp
from scribe.routes.milestones import milestones_bp from fabledassistant.routes.milestones import milestones_bp
from scribe.routes.task_logs import task_logs_bp from fabledassistant.routes.task_logs import task_logs_bp
from scribe.routes.projects import projects_bp from fabledassistant.routes.projects import projects_bp
from scribe.routes.settings import settings_bp from fabledassistant.routes.settings import settings_bp
from scribe.routes.tasks import tasks_bp from fabledassistant.routes.tasks import tasks_bp
from scribe.routes.groups import groups_bp from fabledassistant.routes.groups import groups_bp
from scribe.routes.shares import shares_bp from fabledassistant.routes.shares import shares_bp
from scribe.routes.in_app_notifications import notifications_bp from fabledassistant.routes.in_app_notifications import notifications_bp
from scribe.routes.users import users_bp from fabledassistant.routes.users import users_bp
from scribe.routes.api_keys import api_keys_bp from fabledassistant.routes.api_keys import api_keys_bp
from scribe.routes.events import events_bp from fabledassistant.routes.events import events_bp
from scribe.routes.search import search_bp from fabledassistant.routes.search import search_bp
from scribe.routes.profile import profile_bp from fabledassistant.routes.profile import profile_bp
from scribe.routes.knowledge import knowledge_bp from fabledassistant.routes.knowledge import knowledge_bp
from scribe.routes.rulebooks import rulebooks_bp from fabledassistant.routes.rulebooks import rulebooks_bp
from scribe.routes.trash import trash_bp from fabledassistant.routes.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp from fabledassistant.mcp import mount_mcp
from scribe.mcp import mount_mcp
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -88,7 +87,6 @@ def create_app() -> Quart:
app.register_blueprint(knowledge_bp) app.register_blueprint(knowledge_bp)
app.register_blueprint(rulebooks_bp) app.register_blueprint(rulebooks_bp)
app.register_blueprint(trash_bp) app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
@app.before_request @app.before_request
async def before_request(): async def before_request():
@@ -115,7 +113,7 @@ def create_app() -> Quart:
# Log usage for API requests (skip logs endpoint to avoid recursion) # Log usage for API requests (skip logs endpoint to avoid recursion)
if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"): if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"):
try: try:
from scribe.services.logging import log_usage from fabledassistant.services.logging import log_usage
user = getattr(g, "user", None) user = getattr(g, "user", None)
await log_usage( await log_usage(
@@ -150,14 +148,12 @@ def create_app() -> Quart:
async def startup(): async def startup():
import asyncio import asyncio
from scribe.services.auth import start_auth_token_retention_loop from fabledassistant.services.embeddings import backfill_note_embeddings
from scribe.services.embeddings import backfill_note_embeddings from fabledassistant.services.logging import start_log_retention_loop
from scribe.services.logging import start_log_retention_loop from fabledassistant.services.notifications import start_notification_loop
from scribe.services.notifications import start_notification_loop
start_log_retention_loop() start_log_retention_loop()
start_notification_loop() start_notification_loop()
start_auth_token_retention_loop()
# Backfill embeddings for any notes that don't have one. Runs in the # Backfill embeddings for any notes that don't have one. Runs in the
# background so it never blocks the server from accepting requests. # background so it never blocks the server from accepting requests.
@@ -170,36 +166,36 @@ def create_app() -> Quart:
asyncio.create_task(_delayed_backfill()) asyncio.create_task(_delayed_backfill())
# Event scheduler (reminders + CalDAV pull sync) # Event scheduler (reminders + CalDAV pull sync)
from scribe.services.event_scheduler import start_event_scheduler from fabledassistant.services.event_scheduler import start_event_scheduler
start_event_scheduler(asyncio.get_running_loop()) start_event_scheduler(asyncio.get_running_loop())
# Version-pinning scheduler (daily auto-pin scan at 03:00 UTC) # Version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
from scribe.services.version_pinning_scheduler import ( from fabledassistant.services.version_pinning_scheduler import (
start_version_pinning_scheduler, start_version_pinning_scheduler,
) )
start_version_pinning_scheduler(asyncio.get_running_loop()) start_version_pinning_scheduler(asyncio.get_running_loop())
# Trash retention scheduler (daily expired-trash purge at 03:30 UTC) # Trash retention scheduler (daily expired-trash purge at 03:30 UTC)
from scribe.services.trash_scheduler import start_trash_scheduler from fabledassistant.services.trash_scheduler import start_trash_scheduler
start_trash_scheduler(asyncio.get_running_loop()) start_trash_scheduler(asyncio.get_running_loop())
# Diagnostic instrumentation — heartbeat, signal handlers, asyncio # Diagnostic instrumentation — heartbeat, signal handlers, asyncio
# exception hook. Cheap (~1 log line/min), high diagnostic value when # exception hook. Cheap (~1 log line/min), high diagnostic value when
# the app crashes mysteriously. See services/diagnostics.py. # the app crashes mysteriously. See services/diagnostics.py.
from scribe.services.diagnostics import start_diagnostics from fabledassistant.services.diagnostics import start_diagnostics
start_diagnostics(asyncio.get_running_loop()) start_diagnostics(asyncio.get_running_loop())
@app.after_serving @app.after_serving
async def shutdown(): async def shutdown():
from scribe.services.event_scheduler import stop_event_scheduler from fabledassistant.services.event_scheduler import stop_event_scheduler
stop_event_scheduler() stop_event_scheduler()
from scribe.services.version_pinning_scheduler import ( from fabledassistant.services.version_pinning_scheduler import (
stop_version_pinning_scheduler, stop_version_pinning_scheduler,
) )
stop_version_pinning_scheduler() stop_version_pinning_scheduler()
from scribe.services.trash_scheduler import stop_trash_scheduler from fabledassistant.services.trash_scheduler import stop_trash_scheduler
stop_trash_scheduler() stop_trash_scheduler()
from scribe.services.diagnostics import stop_diagnostics from fabledassistant.services.diagnostics import stop_diagnostics
stop_diagnostics() stop_diagnostics()
@app.route("/") @app.route("/")
@@ -249,7 +245,7 @@ def create_app() -> Quart:
logger.exception("Internal server error on %s %s", request.method, request.path) logger.exception("Internal server error on %s %s", request.method, request.path)
try: try:
from scribe.services.logging import log_error from fabledassistant.services.logging import log_error
user = getattr(g, "user", None) user = getattr(g, "user", None)
await log_error( await log_error(
@@ -2,8 +2,8 @@ import functools
from quart import g, jsonify, request, session from quart import g, jsonify, request, session
from scribe.services.auth import get_user_by_id from fabledassistant.services.auth import get_user_by_id
from scribe.services.api_keys import lookup_key from fabledassistant.services.api_keys import lookup_key
def _check_auth(f, required_role: str | None = None): def _check_auth(f, required_role: str | None = None):
@@ -20,7 +20,7 @@ class Config:
DATABASE_URL: str = _read_secret( DATABASE_URL: str = _read_secret(
"DATABASE_URL", "DATABASE_URL",
"DATABASE_URL_FILE", "DATABASE_URL_FILE",
"postgresql+asyncpg://scribe:scribe@localhost:5432/scribe", "postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
) )
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me") SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes") SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
@@ -3,6 +3,6 @@
Auth uses the existing `api_keys` table: a Bearer token in the Authorization Auth uses the existing `api_keys` table: a Bearer token in the Authorization
header resolves to a user, and every tool call acts on that user's data only. header resolves to a user, and every tool call acts on that user's data only.
""" """
from scribe.mcp.server import build_mcp_server, mount_mcp from fabledassistant.mcp.server import build_mcp_server, mount_mcp
__all__ = ["build_mcp_server", "mount_mcp"] __all__ = ["build_mcp_server", "mount_mcp"]
+19
View File
@@ -0,0 +1,19 @@
"""MCP-side Bearer token resolution. Reuses the existing api_keys infrastructure."""
from __future__ import annotations
from fabledassistant.services.api_keys import lookup_key
async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
"""Parse an `Authorization: Bearer <token>` header and return the user_id.
Returns None if the header is missing, malformed, or the token is invalid
or revoked. The underlying lookup_key already updates last_used_at on hit.
"""
if not auth_header or not auth_header.startswith("Bearer "):
return None
raw_token = auth_header[len("Bearer "):].strip()
if not raw_token:
return None
api_key = await lookup_key(raw_token)
return api_key.user_id if api_key else None
+176
View File
@@ -0,0 +1,176 @@
"""FastMCP instance + Quart mount-point. Tools are registered in mcp/tools/."""
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from quart import Quart
_INSTRUCTIONS = """
Scribe is the user's self-hosted second-brain and project-management data
store. You (Claude) are the assistant.
Hierarchy: Project -> Milestone -> Task/Note.
What each part is for, and when to reach for it:
- Project: the top-level container for a body of work.
- Milestone: groups related tasks within a project toward a goal (status
active/done). Use one when a chunk of work needs its own arc.
- Task: a unit of actionable work with a lifecycle (status
todo/in_progress/done/cancelled, optional priority). A task is a note with a
status — reach for one when there is something to DO. Record progress over
time with work-logs (add_task_log) rather than rewriting the body.
- Plan: a task with kind=plan — HOW you'll execute a chunk of work. The body
holds the design + step checklist; work-logs record progress. Start one with
start_planning when beginning non-trivial work, before writing code.
- Note: durable free-form knowledge — reference material, decisions, dev-logs.
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
- Typed entities (person/place/list): structured records about people, places,
and checklists.
Mechanics:
- Notes and Tasks share a model; tasks are notes with is_task=True.
- Use the *_note tools for notes, the *_task tools for tasks. Don't mix them.
- Typed entities (person, place, list) are notes with a non-default note_type
plus type-specific columns; use the dedicated *_person / *_place / *_list
tools rather than create_note.
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
unchanged on updates.
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
"not set".
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
an actionable statement plus optional Why and How-to-apply context. When you
start work on a project, get_project(id) returns applicable_rules (the rules
from rulebooks the project subscribes to) and subscribed_rulebooks. Consult
these before making decisions about workflow, conventions, or scope. Full
text (Why / How-to-apply) is available via get_rule(id). You may create new
rules via create_rule when you notice a pattern worth codifying — coordinate
with the operator on whether it belongs in an existing rulebook+topic or a
new one.
Plans are tasks with kind=plan, and Scribe is the canonical home for them.
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
before any brainstorming, design, or plan-writing skill runs. start_planning
seeds the plan body, returns the project's applicable_rules, and gives you the
task id you'll write into. If a skill or habit tells you to save a plan or spec
to `docs/superpowers/plans/*.md` or `docs/superpowers/specs/*.md`, that path is
superseded here: put the spec/plan content in the kind=plan task's body via
update_task, and record progress with add_task_log. Local .md files are not
the record — the task is.
Deletes are recoverable: every delete_* tool moves the entity (and its
descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
see trashed batches, restore(deleted_batch_id) to undo a deletion, and
purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash
auto-purges after the operator's retention window.
"""
def build_mcp_server() -> FastMCP:
"""Build the FastMCP instance with all tools registered.
DNS-rebinding protection is disabled: FastMCP's default allow-list is
just localhost variants, which means any deployment behind a reverse
proxy (Traefik with a hostname like devassistant.traefik.internal,
Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat
model that protection addresses — a malicious browser page rebinding
DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport
behind a reverse proxy with bearer-token auth as the real security
boundary.
"""
# stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
# The stateful default strands Claude Code after a container redeploy —
# it reconnects with the now-unknown session id, the server returns 404,
# and the client won't re-initialize on a 404 (Claude Code issue #60949),
# so the connection stays dead until a manual /mcp retry. Stateless makes
# every request self-contained (bearer-auth only), so a post-deploy
# reconnect just works. Trade-off: no server-pushed list_changed stream,
# which we don't use — tools are re-fetched on reconnect anyway.
mcp = FastMCP(
"scribe",
instructions=_INSTRUCTIONS.strip(),
stateless_http=True,
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False,
),
)
from fabledassistant.mcp.tools import register_all
register_all(mcp)
return mcp
def mount_mcp(app: Quart) -> None:
"""Mount the FastMCP streamable-HTTP ASGI sub-app at /mcp on the Quart app.
A small ASGI middleware between Quart and the FastMCP sub-app validates the
Bearer token against the api_keys table. Authenticated requests have their
user_id attached to the ASGI scope under "scribe_user_id" for tool handlers
to read.
FastMCP's streamable_http session manager owns a task group that must be
running before it can serve requests. In a stand-alone Starlette deployment
that would happen via the Starlette `lifespan` parameter. Since we're hosted
inside Quart, we hook the session manager's `run()` async context manager
into Quart's serving lifecycle (before_serving / after_serving).
"""
from fabledassistant.mcp.auth import resolve_bearer_to_user_id
mcp = build_mcp_server()
mcp_asgi = mcp.streamable_http_app()
app.mcp_instance = mcp
@app.before_serving
async def _start_mcp_session() -> None:
cm = mcp.session_manager.run()
await cm.__aenter__()
app._mcp_session_cm = cm
@app.after_serving
async def _stop_mcp_session() -> None:
cm = getattr(app, "_mcp_session_cm", None)
if cm is not None:
await cm.__aexit__(None, None, None)
async def auth_wrapped(scope, receive, send):
if scope["type"] != "http":
return await mcp_asgi(scope, receive, send)
# ASGI headers are lowercase bytes per spec; lowercase explicitly to be safe.
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
user_id = await resolve_bearer_to_user_id(headers.get("authorization"))
if user_id is None:
await send({
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"www-authenticate", b'Bearer realm="scribe-mcp"'),
],
})
await send({
"type": "http.response.body",
"body": b'{"error":"unauthorized"}',
})
return
scope["scribe_user_id"] = user_id
from fabledassistant.mcp._context import _user_id_ctx
token = _user_id_ctx.set(user_id)
try:
await mcp_asgi(scope, receive, send)
finally:
_user_id_ctx.reset(token)
original_asgi = app.asgi_app
async def dispatch(scope, receive, send):
if scope["type"] == "http":
path = scope.get("path", "")
if path == "/mcp" or path.startswith("/mcp/"):
# Don't rewrite the path: FastMCP's streamable_http_app mounts
# its handler at /mcp by default. If we strip the prefix to "/",
# FastMCP's internal routing returns 404 because there's no
# handler at "/" — only at "/mcp". Pass the scope through
# untouched and let FastMCP's own routing match.
return await auth_wrapped(scope, receive, send)
return await original_asgi(scope, receive, send)
app.asgi_app = dispatch
@@ -4,8 +4,8 @@ Each tool module exposes a `register(mcp)` function that attaches its tools
to a FastMCP instance. `register_all(mcp)` is the single entry point called to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`. from `mcp.server.build_mcp_server`.
""" """
from scribe.mcp.tools import ( from fabledassistant.mcp.tools import (
entities, events, milestones, notes, processes, projects, recent, rulebooks, search, tags, tasks, trash, entities, events, milestones, notes, projects, recent, rulebooks, search, tags, tasks, trash,
) )
@@ -20,6 +20,5 @@ def register_all(mcp) -> None:
tags.register(mcp) tags.register(mcp)
recent.register(mcp) recent.register(mcp)
entities.register(mcp) entities.register(mcp)
processes.register(mcp)
rulebooks.register(mcp) rulebooks.register(mcp)
trash.register(mcp) trash.register(mcp)
@@ -14,9 +14,9 @@ of plain strings for ergonomics; checked-state is reset to False on each call.
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import knowledge as knowledge_svc from fabledassistant.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc from fabledassistant.services import notes as notes_svc
async def _list_by_type(note_type: str, q: str, tag: str, limit: int) -> dict: async def _list_by_type(note_type: str, q: str, tag: str, limit: int) -> dict:
@@ -19,9 +19,9 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import events as events_svc from fabledassistant.services import events as events_svc
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
def _combine(start_date: str, start_time: str) -> datetime: def _combine(start_date: str, start_time: str) -> datetime:
@@ -11,9 +11,9 @@ Sentinels:
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import milestones as milestones_svc from fabledassistant.services import milestones as milestones_svc
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
async def list_milestones(project_id: int) -> dict: async def list_milestones(project_id: int) -> dict:
@@ -13,9 +13,9 @@ Sentinel conventions (inherited from existing fable-mcp tools):
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import notes as notes_svc from fabledassistant.services import notes as notes_svc
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
async def list_notes( async def list_notes(
@@ -23,7 +23,6 @@ async def list_notes(
offset: int = 0, offset: int = 0,
tag: str = "", tag: str = "",
search_text: str = "", search_text: str = "",
project_id: int = 0,
) -> dict: ) -> dict:
"""List notes (non-task documents) stored in Scribe. """List notes (non-task documents) stored in Scribe.
@@ -31,12 +30,6 @@ async def list_notes(
search against title and body. Results are ordered by last-updated descending. search against title and body. Results are ordered by last-updated descending.
Use search for semantic/meaning-based lookup instead of exact keyword search. Use search for semantic/meaning-based lookup instead of exact keyword search.
Args:
project_id: Scope to one project. PASS THE ACTIVE PROJECT'S ID whenever a
project is in scope so you list that project's notes, not every
project's. 0 = no filter (all projects — use only for a deliberate
cross-project view).
""" """
uid = current_user_id() uid = current_user_id()
rows, total = await notes_svc.list_notes( rows, total = await notes_svc.list_notes(
@@ -44,7 +37,6 @@ async def list_notes(
q=search_text or None, q=search_text or None,
tags=[tag] if tag else None, tags=[tag] if tag else None,
is_task=False, is_task=False,
project_id=project_id or None,
limit=max(1, min(limit, 100)), limit=max(1, min(limit, 100)),
offset=max(0, offset), offset=max(0, offset),
) )
@@ -16,18 +16,17 @@ keeps working.
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import milestones as milestones_svc from fabledassistant.services import milestones as milestones_svc
from scribe.services import notes as notes_svc from fabledassistant.services import projects as projects_svc
from scribe.services import projects as projects_svc from fabledassistant.services import rulebooks as rulebooks_svc
from scribe.services import rulebooks as rulebooks_svc from fabledassistant.services import trash as trash_svc
from scribe.services import trash as trash_svc
async def list_projects() -> dict: async def list_projects() -> dict:
"""List all Scribe projects for the current user. """List all Scribe projects for the current user.
Returns id, title, description, goal, status (active/paused/completed/archived), color, Returns id, title, description, goal, status (active/archived), color,
and a short auto-generated summary for each project. and a short auto-generated summary for each project.
""" """
uid = current_user_id() uid = current_user_id()
@@ -35,73 +34,6 @@ async def list_projects() -> dict:
return {"projects": [p.to_dict() for p in rows]} return {"projects": [p.to_dict() for p in rows]}
async def enter_project(project_id: int) -> dict:
"""Session-start handshake: load full context for working on a project.
Call this FIRST whenever you're about to do project-scoped work
(start_planning, create_task, update_*, anything that takes a project_id).
One round-trip returns the project, its applicable rules (both rulebook-
subscribed and project-scoped), milestone progress, open tasks, and
recently-updated notes everything you need to know the lay of the land
before mutating.
No persistent server state: this is a read snapshot. Re-call if the
session goes idle long enough that the data feels stale.
Args:
project_id: The project to enter.
Returns a dict with keys: project, milestone_summary, applicable_rules,
project_rules, subscribed_rulebooks, applicable_rules_truncated,
open_tasks, recent_notes.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=uid,
)
milestone_summary = await milestones_svc.get_project_milestone_summary(
uid, project_id,
)
open_tasks, _ = await notes_svc.list_notes(
uid, is_task=True, project_id=project_id,
status=["todo", "in_progress"], sort="updated_at", limit=10,
)
recent_notes, _ = await notes_svc.list_notes(
uid, is_task=False, project_id=project_id,
sort="updated_at", limit=5,
)
return {
"project": project.to_dict(),
"milestone_summary": milestone_summary,
"applicable_rules": applicable["rules"],
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
"open_tasks": [
{
"id": t.id, "title": t.title, "status": t.status,
"priority": t.priority, "task_kind": t.task_kind,
"milestone_id": t.milestone_id,
}
for t in open_tasks
],
"recent_notes": [
{
"id": n.id, "title": n.title,
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
}
for n in recent_notes
],
}
async def get_project(project_id: int) -> dict: async def get_project(project_id: int) -> dict:
"""Fetch a Scribe project by ID. """Fetch a Scribe project by ID.
@@ -123,9 +55,6 @@ async def get_project(project_id: int) -> dict:
data["applicable_rules"] = applicable["rules"] data["applicable_rules"] = applicable["rules"]
data["applicable_rules_truncated"] = applicable["truncated"] data["applicable_rules_truncated"] = applicable["truncated"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"] data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["project_rules"] = applicable.get("project_rules", [])
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
return data return data
@@ -142,7 +71,7 @@ async def create_project(
title: Project name (required). title: Project name (required).
description: Short summary of what the project is. description: Short summary of what the project is.
goal: The desired outcome or definition of done for the project. goal: The desired outcome or definition of done for the project.
status: one of active (default), paused, completed, archived. status: active (default) or archived.
color: Optional hex colour for the project card (e.g. "#6366f1"). color: Optional hex colour for the project card (e.g. "#6366f1").
""" """
uid = current_user_id() uid = current_user_id()
@@ -172,7 +101,7 @@ async def update_project(
title: New title, or omit to leave unchanged. title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged. description: New description, or omit to leave unchanged.
goal: New goal/definition-of-done, or omit to leave unchanged. goal: New goal/definition-of-done, or omit to leave unchanged.
status: New status one of active, paused, completed, archived. status: New status active or archived.
color: New hex colour, or omit to leave unchanged. color: New hex colour, or omit to leave unchanged.
""" """
uid = current_user_id() uid = current_user_id()
@@ -207,7 +136,6 @@ async def delete_project(project_id: int) -> dict:
def register(mcp) -> None: def register(mcp) -> None:
for fn in ( for fn in (
list_projects, list_projects,
enter_project,
get_project, get_project,
create_project, create_project,
update_project, update_project,
@@ -13,11 +13,11 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.event import Event from fabledassistant.models.event import Event
from scribe.models.note import Note from fabledassistant.models.note import Note
from scribe.models.project import Project from fabledassistant.models.project import Project
async def get_recent(days: int = 7, limit: int = 25) -> dict: async def get_recent(days: int = 7, limit: int = 25) -> dict:
@@ -38,8 +38,7 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
items: list[dict] = [] items: list[dict] = []
async with async_session() as session: async with async_session() as session:
notes = (await session.execute( notes = (await session.execute(
select(Note).where(Note.user_id == uid, Note.updated_at >= since, select(Note).where(Note.user_id == uid, Note.updated_at >= since)
Note.deleted_at.is_(None))
.order_by(Note.updated_at.desc()).limit(limit) .order_by(Note.updated_at.desc()).limit(limit)
)).scalars().all() )).scalars().all()
for n in notes: for n in notes:
@@ -51,8 +50,7 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
}) })
projects = (await session.execute( projects = (await session.execute(
select(Project).where(Project.user_id == uid, select(Project).where(Project.user_id == uid,
Project.updated_at >= since, Project.updated_at >= since)
Project.deleted_at.is_(None))
.order_by(Project.updated_at.desc()).limit(limit) .order_by(Project.updated_at.desc()).limit(limit)
)).scalars().all() )).scalars().all()
for p in projects: for p in projects:
@@ -64,8 +62,7 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
}) })
events = (await session.execute( events = (await session.execute(
select(Event).where(Event.user_id == uid, select(Event).where(Event.user_id == uid,
Event.updated_at >= since, Event.updated_at >= since)
Event.deleted_at.is_(None))
.order_by(Event.updated_at.desc()).limit(limit) .order_by(Event.updated_at.desc()).limit(limit)
)).scalars().all() )).scalars().all()
for e in events: for e in events:
@@ -9,9 +9,9 @@ spec.
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import rulebooks as rulebooks_svc from fabledassistant.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
# ── Rulebook CRUD ─────────────────────────────────────────────────────── # ── Rulebook CRUD ───────────────────────────────────────────────────────
@@ -54,26 +54,14 @@ async def create_rulebook(title: str, description: str = "") -> dict:
async def update_rulebook( async def update_rulebook(
rulebook_id: int, title: str = "", description: str = "", rulebook_id: int, title: str = "", description: str = "",
always_on: bool | None = None,
) -> dict: ) -> dict:
"""Update an existing rulebook. Only non-empty fields are changed. """Update an existing rulebook. Only non-empty fields are changed."""
Args:
rulebook_id: Rulebook to update.
title: New title. Empty string leaves unchanged.
description: New description. Empty string leaves unchanged.
always_on: When True, rules in this rulebook are loaded at session
start by list_always_on_rules regardless of project context.
Pass None to leave unchanged.
"""
uid = current_user_id() uid = current_user_id()
fields: dict = {} fields: dict = {}
if title: if title:
fields["title"] = title fields["title"] = title
if description: if description:
fields["description"] = description fields["description"] = description
if always_on is not None:
fields["always_on"] = always_on
rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields) rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
if rb is None: if rb is None:
raise ValueError(f"rulebook {rulebook_id} not found") raise ValueError(f"rulebook {rulebook_id} not found")
@@ -212,28 +200,6 @@ async def list_rules(
} }
async def list_always_on_rules() -> dict:
"""Return all rules from rulebooks flagged always_on for the current user.
Call this at session start. Treat the returned rules as binding for the
session they apply regardless of which project (if any) is in scope.
Pair with get_project(id).applicable_rules when working on a specific
project to also load that project's subscription-derived rules.
"""
uid = current_user_id()
rules = await rulebooks_svc.list_always_on_rules(uid)
return {
"rules": [
{
"id": r.id, "title": r.title, "statement": r.statement,
"topic_id": r.topic_id,
}
for r in rules
],
"total": len(rules),
}
async def get_rule(rule_id: int) -> dict: async def get_rule(rule_id: int) -> dict:
"""Fetch a rule by id — full statement + why + how_to_apply.""" """Fetch a rule by id — full statement + why + how_to_apply."""
uid = current_user_id() uid = current_user_id()
@@ -247,7 +213,7 @@ async def create_rule(
topic_id: int, title: str, statement: str, topic_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0, why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> dict: ) -> dict:
"""Create a new rule under a topic (cross-project rulebook rule). """Create a new rule under a topic.
Args: Args:
topic_id: The topic to attach the rule to. topic_id: The topic to attach the rule to.
@@ -256,9 +222,6 @@ async def create_rule(
why: Optional rationale the reason the rule exists. why: Optional rationale the reason the rule exists.
how_to_apply: Optional operationalization when / where it kicks in. how_to_apply: Optional operationalization when / where it kicks in.
order_index: Display order within the topic (default 0). order_index: Display order within the topic (default 0).
For a rule that applies to a single project only, use create_project_rule
instead no rulebook+topic ceremony required.
""" """
uid = current_user_id() uid = current_user_id()
rule = await rulebooks_svc.create_rule( rule = await rulebooks_svc.create_rule(
@@ -269,35 +232,6 @@ async def create_rule(
return rule.to_dict() return rule.to_dict()
async def create_project_rule(
project_id: int, statement: str, title: str = "",
why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> dict:
"""Create a rule scoped to a single project (no rulebook needed).
Use this when a rule only applies to one project it bypasses the
Rulebook -> Topic -> Rule ceremony. The rule is returned in get_project's
applicable_rules (under project_rules) and in list_rules(project_id=...).
Args:
project_id: The project to attach the rule to.
statement: The actionable instruction (required). 1-2 sentences.
title: Short imperative title. If empty, derived from the first ~50
characters of statement.
why: Optional rationale the reason the rule exists.
how_to_apply: Optional operationalization when / where it kicks in.
order_index: Display order within the project's rule list (default 0).
"""
uid = current_user_id()
derived_title = title.strip() or statement.strip().split(".")[0][:50]
rule = await rulebooks_svc.create_project_rule(
project_id=project_id, user_id=uid,
title=derived_title, statement=statement,
why=why, how_to_apply=how_to_apply, order_index=order_index,
)
return rule.to_dict()
async def update_rule( async def update_rule(
rule_id: int, title: str = "", statement: str = "", rule_id: int, title: str = "", statement: str = "",
why: str = "", how_to_apply: str = "", order_index: int = -1, why: str = "", how_to_apply: str = "", order_index: int = -1,
@@ -322,7 +256,7 @@ async def update_rule(
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict: async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
"""Move a rule to the trash (recoverable). Requires confirmed=True.""" """Permanently delete a rule. Requires confirmed=True."""
uid = current_user_id() uid = current_user_id()
rule = await rulebooks_svc.get_rule(rule_id, uid) rule = await rulebooks_svc.get_rule(rule_id, uid)
if rule is None: if rule is None:
@@ -330,8 +264,8 @@ async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
if not confirmed: if not confirmed:
return { return {
"warning": ( "warning": (
f"Rule {rule_id} ('{rule.title}') will be moved to the trash " f"Rule {rule_id} ('{rule.title}') will be permanently deleted. "
f"(recoverable via restore). Pass confirmed=True to proceed." f"Pass confirmed=True to proceed."
), ),
"confirmed_required": True, "confirmed_required": True,
} }
@@ -364,70 +298,11 @@ async def unsubscribe_project_from_rulebook(
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False} return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False}
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def suppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
"""Mute a single rulebook rule for one project.
The rule stays in its rulebook for other projects; only this project
skips it. Idempotent. Use unsuppress_rule_for_project to re-enable.
Project-scoped rules (create_project_rule) are NOT suppressible delete
them with delete_rule instead.
"""
uid = current_user_id()
await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=uid,
)
return {"project_id": project_id, "rule_id": rule_id, "suppressed": True}
async def unsuppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
"""Re-enable a previously-suppressed rule for one project. Idempotent."""
uid = current_user_id()
await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=uid,
)
return {"project_id": project_id, "rule_id": rule_id, "suppressed": False}
async def suppress_topic_for_project(
project_id: int, topic_id: int,
) -> dict:
"""Mute every rule under a topic for one project.
Equivalent to suppressing each rule in the topic individually, but
auto-includes new rules added to the topic later. Idempotent.
"""
uid = current_user_id()
await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=uid,
)
return {"project_id": project_id, "topic_id": topic_id, "suppressed": True}
async def unsuppress_topic_for_project(
project_id: int, topic_id: int,
) -> dict:
"""Re-enable a previously-suppressed topic for one project. Idempotent."""
uid = current_user_id()
await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=uid,
)
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
def register(mcp) -> None: def register(mcp) -> None:
for fn in ( for fn in (
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook, list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
list_topics, create_topic, update_topic, delete_topic, list_topics, create_topic, update_topic, delete_topic,
list_rules, list_always_on_rules, get_rule, list_rules, get_rule, create_rule, update_rule, delete_rule,
create_rule, create_project_rule, update_rule, delete_rule,
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook, subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
suppress_rule_for_project, unsuppress_rule_for_project,
suppress_topic_for_project, unsuppress_topic_for_project,
): ):
mcp.tool(name=fn.__name__)(fn) mcp.tool(name=fn.__name__)(fn)
@@ -7,34 +7,17 @@ working. Differences from fable-mcp:
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services.embeddings import semantic_search_notes from fabledassistant.services.embeddings import semantic_search_notes
async def search( async def search(q: str, content_type: str = "all", limit: int = 10) -> dict:
q: str, """Semantic search over the user's notes and tasks.
content_type: str = "all",
limit: int = 10,
project_id: int = 0,
) -> dict:
"""Semantic search over the user's existing notes and tasks — Scribe's recall.
Reach for this BEFORE answering a question about the user's work or starting
a task: the user's second-brain almost always already holds related prior
art. Check for an existing ticket before opening a new one (search with
content_type='task'), and for prior notes/decisions before re-deriving them.
Treating Scribe as the first place to look not a place to only write is
the difference between it being a trustworthy record and a write-only log.
Args: Args:
q: search query string. q: search query string.
content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only). content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only).
limit: maximum number of results (1-50). limit: maximum number of results (1-50).
project_id: Scope results to one project. PASS THE ACTIVE PROJECT'S ID
whenever a project is in scope (the one you entered with
enter_project) otherwise this searches across ALL projects and
bleeds unrelated work into the result set. 0 = search everything
(use only when you genuinely want a cross-project sweep).
Returns: Returns:
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}], {"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
@@ -45,7 +28,6 @@ async def search(
is_task = {"note": False, "task": True}.get(content_type) # None => any is_task = {"note": False, "task": True}.get(content_type) # None => any
raw = await semantic_search_notes( raw = await semantic_search_notes(
uid, q, limit=limit, is_task=is_task, uid, q, limit=limit, is_task=is_task,
project_id=project_id or None,
) )
return { return {
"results": [ "results": [
@@ -8,9 +8,9 @@ from __future__ import annotations
from sqlalchemy import select from sqlalchemy import select
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.note import Note from fabledassistant.models.note import Note
def _aggregate_tag_counts(tag_lists) -> dict[str, int]: def _aggregate_tag_counts(tag_lists) -> dict[str, int]:
@@ -39,7 +39,7 @@ async def list_tags(limit: int = 50) -> dict:
limit = max(1, min(limit, 200)) limit = max(1, min(limit, 200))
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
select(Note.tags).where(Note.user_id == uid, Note.deleted_at.is_(None)) select(Note.tags).where(Note.user_id == uid)
) )
tag_lists = [row[0] for row in result.all()] tag_lists = [row[0] for row in result.all()]
counts = _aggregate_tag_counts(tag_lists) counts = _aggregate_tag_counts(tag_lists)
@@ -14,16 +14,16 @@ Sentinels (preserved from existing fable-mcp):
what makes a Note a Task) what makes a Note a Task)
- priority="none" sets explicit no-priority; priority="" is "leave unchanged" - priority="none" sets explicit no-priority; priority="" is "leave unchanged"
- project_id=0 / milestone_id=0 / parent_id=0 "no association" on create, - project_id=0 / milestone_id=0 / parent_id=0 "no association" on create,
"leave unchanged" on update; on update, -1 clears the FK (sets it NULL) "leave unchanged" on update
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import notes as notes_svc from fabledassistant.services import notes as notes_svc
from scribe.services import planning as planning_svc from fabledassistant.services import planning as planning_svc
from scribe.services import rulebooks as rulebooks_svc from fabledassistant.services import rulebooks as rulebooks_svc
from scribe.services import task_logs as task_logs_svc from fabledassistant.services import task_logs as task_logs_svc
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
async def list_tasks( async def list_tasks(
@@ -37,10 +37,7 @@ async def list_tasks(
Args: Args:
status: Filter by status one of: todo, in_progress, done, cancelled. Omit for all. status: Filter by status one of: todo, in_progress, done, cancelled. Omit for all.
project_id: Filter to a specific project. PASS THE ACTIVE PROJECT'S ID project_id: Filter to a specific project. Use 0 for no filter.
whenever a project is in scope so you list that project's tasks, not
every project's. 0 = no filter (all projects — use only for a
deliberate cross-project view).
kind: Filter by task kind 'work' or 'plan'. Omit (empty) for all kinds. kind: Filter by task kind 'work' or 'plan'. Omit (empty) for all kinds.
Results are ordered by last-updated descending. Results are ordered by last-updated descending.
@@ -85,9 +82,6 @@ async def get_task(task_id: int) -> dict:
data["applicable_rules"] = applicable["rules"] data["applicable_rules"] = applicable["rules"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"] data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["applicable_rules_truncated"] = applicable["truncated"] data["applicable_rules_truncated"] = applicable["truncated"]
data["project_rules"] = applicable.get("project_rules", [])
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
return data return data
@@ -146,14 +140,10 @@ async def update_task(
task_id: ID of the task to update. task_id: ID of the task to update.
title: New title, or omit to leave unchanged. title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged. body: New markdown body, or omit to leave unchanged.
status: New status one of: todo, in_progress, done, cancelled. Drive status: New status one of: todo, in_progress, done, cancelled.
the lifecycle: set in_progress when you start, done when complete
don't leave finished work at todo.
priority: New priority one of: none, low, medium, high. priority: New priority one of: none, low, medium, high.
project_id: New project. 0 = leave unchanged, -1 = clear (remove from project_id: New project. Omit (0) to leave unchanged.
its project; also clears the milestone), positive = set. milestone_id: New milestone. Omit (0) to leave unchanged.
milestone_id: New milestone. 0 = leave unchanged, -1 = clear (remove
from its milestone), positive = set.
""" """
uid = current_user_id() uid = current_user_id()
fields: dict = {} fields: dict = {}
@@ -165,15 +155,9 @@ async def update_task(
fields["status"] = status fields["status"] = status
if priority: if priority:
fields["priority"] = priority fields["priority"] = priority
# Optional FKs: 0 = leave unchanged, -1 = clear (set NULL), positive = set. if project_id:
if project_id == -1:
fields["project_id"] = None
fields["milestone_id"] = None # a milestone can't outlive its project
elif project_id:
fields["project_id"] = project_id fields["project_id"] = project_id
if milestone_id == -1: if milestone_id:
fields["milestone_id"] = None
elif milestone_id:
fields["milestone_id"] = milestone_id fields["milestone_id"] = milestone_id
note = await notes_svc.update_note(uid, task_id, **fields) note = await notes_svc.update_note(uid, task_id, **fields)
if note is None: if note is None:
@@ -6,8 +6,8 @@ the deleted_batch_id that a delete operation returns.
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
async def list_trash() -> dict: async def list_trash() -> dict:
+42
View File
@@ -0,0 +1,42 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from fabledassistant.config import Config
engine = create_async_engine(
Config.DATABASE_URL,
echo=False,
pool_pre_ping=True,
pool_recycle=1800,
)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin # noqa: E402, F401
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
from fabledassistant.models.setting import Setting # noqa: E402, F401
from fabledassistant.models.user import User # noqa: E402, F401
from fabledassistant.models.app_log import AppLog # noqa: E402, F401
from fabledassistant.models.password_reset import PasswordResetToken # noqa: E402, F401
from fabledassistant.models.invitation import InvitationToken # noqa: E402, F401
from fabledassistant.models.embedding import NoteEmbedding # noqa: E402, F401
from fabledassistant.models.project import Project # noqa: E402, F401
from fabledassistant.models.event import Event # noqa: E402, F401
from fabledassistant.models.milestone import Milestone # noqa: E402, F401
from fabledassistant.models.task_log import TaskLog # noqa: E402, F401
from fabledassistant.models.note_draft import NoteDraft # noqa: E402, F401
from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401
from fabledassistant.models.group import Group, GroupMembership # noqa: E402, F401
from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, F401
from fabledassistant.models.notification import Notification # noqa: E402, F401
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401
from fabledassistant.models.rulebook import ( # noqa: E402, F401
Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
)
@@ -3,8 +3,8 @@ from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import CreatedAtMixin from fabledassistant.models.base import CreatedAtMixin
class ApiKey(Base, CreatedAtMixin): class ApiKey(Base, CreatedAtMixin):
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Index, Integer, Text from sqlalchemy import DateTime, Float, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
class AppLog(Base): class AppLog(Base):
@@ -4,7 +4,7 @@ from sqlalchemy import DateTime, ForeignKey, Integer
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
class NoteEmbedding(Base): class NoteEmbedding(Base):
@@ -3,8 +3,8 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import SoftDeleteMixin from fabledassistant.models.base import SoftDeleteMixin
class Event(Base, SoftDeleteMixin): class Event(Base, SoftDeleteMixin):
@@ -3,8 +3,8 @@ from datetime import datetime, timezone
from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import CreatedAtMixin, TimestampMixin from fabledassistant.models.base import CreatedAtMixin, TimestampMixin
class Group(Base, TimestampMixin): class Group(Base, TimestampMixin):
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
class InvitationToken(Base): class InvitationToken(Base):
@@ -1,8 +1,8 @@
from sqlalchemy import ForeignKey, Integer, Text from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin
class Milestone(Base, TimestampMixin, SoftDeleteMixin): class Milestone(Base, TimestampMixin, SoftDeleteMixin):
@@ -5,8 +5,8 @@ from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin
class TaskStatus(str, enum.Enum): class TaskStatus(str, enum.Enum):
@@ -1,8 +1,8 @@
from sqlalchemy import ForeignKey, Integer, Text from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin from fabledassistant.models.base import TimestampMixin
class NoteDraft(Base, TimestampMixin): class NoteDraft(Base, TimestampMixin):
@@ -1,8 +1,8 @@
from sqlalchemy import ARRAY, ForeignKey, Integer, Text from sqlalchemy import ARRAY, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import CreatedAtMixin from fabledassistant.models.base import CreatedAtMixin
class NoteVersion(Base, CreatedAtMixin): class NoteVersion(Base, CreatedAtMixin):
@@ -4,8 +4,8 @@ from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import CreatedAtMixin from fabledassistant.models.base import CreatedAtMixin
class Notification(Base, CreatedAtMixin): class Notification(Base, CreatedAtMixin):
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
class PasswordResetToken(Base): class PasswordResetToken(Base):
@@ -1,13 +1,13 @@
import enum import enum
from sqlalchemy import ForeignKey, Integer, Text from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin
class ProjectStatus(str, enum.Enum): class ProjectStatus(str, enum.Enum):
active = "active" active = "active"
paused = "paused"
completed = "completed" completed = "completed"
archived = "archived" archived = "archived"
@@ -21,6 +21,10 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
goal: Mapped[str] = mapped_column(Text, default="") goal: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="active") status: Mapped[str] = mapped_column(Text, default="active")
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
auto_summary: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
summary_updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -31,6 +35,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"goal": self.goal, "goal": self.goal,
"status": self.status, "status": self.status,
"color": self.color, "color": self.color,
"auto_summary": self.auto_summary,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
@@ -1,10 +1,10 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, Text, text from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, Table, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import SoftDeleteMixin from fabledassistant.models.base import SoftDeleteMixin
class Rulebook(Base, SoftDeleteMixin): class Rulebook(Base, SoftDeleteMixin):
@@ -16,9 +16,6 @@ class Rulebook(Base, SoftDeleteMixin):
) )
title: Mapped[str] = mapped_column(Text) title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
always_on: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False, server_default="false"
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
) )
@@ -34,7 +31,6 @@ class Rulebook(Base, SoftDeleteMixin):
"owner_user_id": self.owner_user_id, "owner_user_id": self.owner_user_id,
"title": self.title, "title": self.title,
"description": self.description or "", "description": self.description or "",
"always_on": self.always_on,
"created_at": self.created_at.isoformat() if self.created_at else None, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
} }
@@ -42,13 +38,8 @@ class Rulebook(Base, SoftDeleteMixin):
class RulebookTopic(Base, SoftDeleteMixin): class RulebookTopic(Base, SoftDeleteMixin):
__tablename__ = "rulebook_topics" __tablename__ = "rulebook_topics"
# Partial unique: a title is unique among LIVE topics in a rulebook, so a
# trashed topic doesn't block recreating/restoring the same title.
__table_args__ = ( __table_args__ = (
Index( UniqueConstraint("rulebook_id", "title", name="uq_topic_per_rulebook"),
"uq_topic_per_rulebook", "rulebook_id", "title",
unique=True, postgresql_where=text("deleted_at IS NULL"),
),
) )
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -81,28 +72,13 @@ class RulebookTopic(Base, SoftDeleteMixin):
class Rule(Base, SoftDeleteMixin): class Rule(Base, SoftDeleteMixin):
__tablename__ = "rules" __tablename__ = "rules"
# Partial unique: title unique among LIVE rules in a topic (soft-deleted
# rules don't block recreating/restoring the same title).
__table_args__ = ( __table_args__ = (
Index( UniqueConstraint("topic_id", "title", name="uq_rule_per_topic"),
"uq_rule_per_topic", "topic_id", "title",
unique=True, postgresql_where=text("deleted_at IS NULL"),
),
) )
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
# Exactly one of topic_id / project_id is set — enforced by CHECK topic_id: Mapped[int] = mapped_column(
# constraint ck_rule_topic_xor_project (migration 0059). BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE")
topic_id: Mapped[int | None] = mapped_column(
BigInteger,
ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
nullable=True,
)
project_id: Mapped[int | None] = mapped_column(
BigInteger,
ForeignKey("projects.id", ondelete="CASCADE"),
nullable=True,
index=True,
) )
title: Mapped[str] = mapped_column(Text) title: Mapped[str] = mapped_column(Text)
statement: Mapped[str] = mapped_column(Text) statement: Mapped[str] = mapped_column(Text)
@@ -122,7 +98,6 @@ class Rule(Base, SoftDeleteMixin):
return { return {
"id": self.id, "id": self.id,
"topic_id": self.topic_id, "topic_id": self.topic_id,
"project_id": self.project_id,
"title": self.title, "title": self.title,
"statement": self.statement, "statement": self.statement,
"why": self.why or "", "why": self.why or "",
@@ -141,22 +116,3 @@ project_rulebook_subscriptions = Table(
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True), Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)), Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
) )
# Suppressions — let a project mute individual rules or whole topics from
# rulebooks it subscribes to, without unsubscribing the rulebook itself.
# FKs CASCADE so the row vanishes when its parent is removed.
project_rule_suppressions = Table(
"project_rule_suppressions",
Base.metadata,
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
project_topic_suppressions = Table(
"project_topic_suppressions",
Base.metadata,
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("topic_id", BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
@@ -1,7 +1,7 @@
from sqlalchemy import ForeignKey, Integer, Text from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
class Setting(Base): class Setting(Base):
@@ -1,8 +1,8 @@
from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin from fabledassistant.models.base import TimestampMixin
class ProjectShare(Base, TimestampMixin): class ProjectShare(Base, TimestampMixin):
@@ -1,8 +1,8 @@
from sqlalchemy import ForeignKey, Integer, Text from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin from fabledassistant.models.base import TimestampMixin
class TaskLog(Base, TimestampMixin): class TaskLog(Base, TimestampMixin):
@@ -1,8 +1,8 @@
from sqlalchemy import Index, Integer, Text from sqlalchemy import Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import CreatedAtMixin from fabledassistant.models.base import CreatedAtMixin
class User(Base, CreatedAtMixin): class User(Base, CreatedAtMixin):
@@ -1,9 +1,11 @@
from sqlalchemy import ForeignKey, Integer, Text from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin from fabledassistant.models.base import TimestampMixin
class UserProfile(Base, TimestampMixin): class UserProfile(Base, TimestampMixin):
@@ -25,6 +27,13 @@ class UserProfile(Base, TimestampMixin):
interests: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True) interests: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
# {days: ["Mon","Tue",...], start: "09:00", end: "17:00"} # {days: ["Mon","Tue",...], start: "09:00", end: "17:00"}
work_schedule: Mapped[dict | None] = mapped_column(JSONB, nullable=True) work_schedule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# LLM-consolidated summary of learned preferences
learned_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
# [{date: "YYYY-MM-DD", bullets: "..."}, ...]
observations_raw: Mapped[list | None] = mapped_column(JSONB, nullable=True)
observations_updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -36,4 +45,11 @@ class UserProfile(Base, TimestampMixin):
"tone": self.tone or "casual", "tone": self.tone or "casual",
"interests": self.interests or [], "interests": self.interests or [],
"work_schedule": self.work_schedule or {}, "work_schedule": self.work_schedule or {},
"learned_summary": self.learned_summary or "",
"observations_count": len(self.observations_raw or []),
"observations_updated_at": (
self.observations_updated_at.isoformat()
if self.observations_updated_at
else None
),
} }
@@ -3,8 +3,8 @@ import json
from quart import Blueprint, Response, g, jsonify, request from quart import Blueprint, Response, g, jsonify, request
from scribe.auth import admin_required, login_required, get_current_user_id from fabledassistant.auth import admin_required, login_required, get_current_user_id
from scribe.services.auth import ( from fabledassistant.services.auth import (
create_invitation, create_invitation,
delete_user, delete_user,
is_registration_open, is_registration_open,
@@ -13,15 +13,15 @@ from scribe.services.auth import (
revoke_invitation, revoke_invitation,
set_registration_open, set_registration_open,
) )
from scribe.services.backup import ( from fabledassistant.services.backup import (
export_full_backup, export_full_backup,
export_user_backup, export_user_backup,
restore_full_backup, restore_full_backup,
) )
from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email from fabledassistant.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from scribe.services.logging import get_logs, get_log_stats, log_audit from fabledassistant.services.logging import get_logs, get_log_stats, log_audit
from scribe.services.notifications import send_invitation_email from fabledassistant.services.notifications import send_invitation_email
from scribe.services.settings import set_setting, set_settings_batch from fabledassistant.services.settings import set_setting, set_settings_batch
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin") admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
@@ -1,7 +1,7 @@
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys") api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
@@ -6,10 +6,10 @@ import secrets
from quart import Blueprint, g, jsonify, redirect, request, session from quart import Blueprint, g, jsonify, redirect, request, session
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.config import Config from fabledassistant.config import Config
from scribe.rate_limit import is_rate_limited from fabledassistant.rate_limit import is_rate_limited
from scribe.services.auth import ( from fabledassistant.services.auth import (
authenticate, authenticate,
change_password, change_password,
create_password_reset_token, create_password_reset_token,
@@ -26,14 +26,14 @@ from scribe.services.auth import (
validate_invitation_token, validate_invitation_token,
verify_password, verify_password,
) )
from scribe.services.logging import log_audit from fabledassistant.services.logging import log_audit
from scribe.services.notifications import ( from fabledassistant.services.notifications import (
notify_security_event, notify_security_event,
send_password_reset_email, send_password_reset_email,
send_password_reset_success_email, send_password_reset_success_email,
) )
from scribe.services.email import get_base_url, is_smtp_configured from fabledassistant.services.email import get_base_url, is_smtp_configured
from scribe.services.oauth import ( from fabledassistant.services.oauth import (
build_auth_url, build_auth_url,
exchange_code, exchange_code,
get_userinfo, get_userinfo,
@@ -5,8 +5,8 @@ from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required from fabledassistant.auth import login_required
import scribe.services.events as events_svc import fabledassistant.services.events as events_svc
events_bp = Blueprint("events", __name__, url_prefix="/api/events") events_bp = Blueprint("events", __name__, url_prefix="/api/events")
@@ -126,7 +126,7 @@ async def update_event(event_id: int):
@events_bp.delete("/<int:event_id>") @events_bp.delete("/<int:event_id>")
@login_required @login_required
async def delete_event(event_id: int): async def delete_event(event_id: int):
from scribe.services.trash import delete as trash_delete from fabledassistant.services.trash import delete as trash_delete
batch = await trash_delete(_get_current_user_id(), "event", event_id) batch = await trash_delete(_get_current_user_id(), "event", event_id)
if batch is None: if batch is None:
return jsonify({"error": "Event not found"}), 404 return jsonify({"error": "Event not found"}), 404
@@ -137,6 +137,6 @@ async def delete_event(event_id: int):
@login_required @login_required
async def sync_caldav(): async def sync_caldav():
"""Trigger a CalDAV pull sync for the current user.""" """Trigger a CalDAV pull sync for the current user."""
from scribe.services.caldav_sync import sync_user_events from fabledassistant.services.caldav_sync import sync_user_events
result = await sync_user_events(user_id=_get_current_user_id()) result = await sync_user_events(user_id=_get_current_user_id())
return jsonify(result) return jsonify(result)
@@ -6,9 +6,9 @@ from datetime import datetime, timezone
from quart import Blueprint, Response, request from quart import Blueprint, Response, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.note import Note from fabledassistant.models.note import Note
from sqlalchemy import select from sqlalchemy import select
export_bp = Blueprint("export", __name__) export_bp = Blueprint("export", __name__)
@@ -83,7 +83,7 @@ async def export_data():
status=200, status=200,
headers={ headers={
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json; charset=utf-8",
"Content-Disposition": f'attachment; filename="scribe-{stamp}.json"', "Content-Disposition": f'attachment; filename="fabledassistant-{stamp}.json"',
}, },
) )
@@ -112,6 +112,6 @@ async def export_data():
status=200, status=200,
headers={ headers={
"Content-Type": "application/zip", "Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="scribe-{stamp}.zip"', "Content-Disposition": f'attachment; filename="fabledassistant-{stamp}.zip"',
}, },
) )
@@ -2,9 +2,9 @@ import asyncio
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from scribe.auth import get_current_user_id, login_required from fabledassistant.auth import get_current_user_id, login_required
from scribe.services import groups as group_svc from fabledassistant.services import groups as group_svc
from scribe.services.notifications import notify_group_added from fabledassistant.services.notifications import notify_group_added
groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups") groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups")
@@ -1,7 +1,7 @@
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required from fabledassistant.auth import get_current_user_id, login_required
from scribe.services.notifications import ( from fabledassistant.services.notifications import (
list_in_app_notifications, list_in_app_notifications,
mark_all_notifications_read, mark_all_notifications_read,
mark_notification_read, mark_notification_read,
@@ -3,14 +3,14 @@ import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required from fabledassistant.auth import get_current_user_id, login_required
from scribe.routes.utils import parse_pagination from fabledassistant.routes.utils import parse_pagination
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge") knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
_VALID_TYPES = {"note", "person", "place", "list", "task", "plan", "process"} _VALID_TYPES = {"note", "person", "place", "list", "task", "plan"}
_VALID_SORTS = {"modified", "created", "alpha", "type"} _VALID_SORTS = {"modified", "created", "alpha", "type"}
@@ -42,7 +42,7 @@ async def list_knowledge():
limit, offset = parse_pagination(default_limit=24, max_limit=100) limit, offset = parse_pagination(default_limit=24, max_limit=100)
page = max(1, int(request.args.get("page", 1))) page = max(1, int(request.args.get("page", 1)))
from scribe.services.knowledge import query_knowledge from fabledassistant.services.knowledge import query_knowledge
items, total = await query_knowledge( items, total = await query_knowledge(
user_id=uid, user_id=uid,
note_type=note_type, note_type=note_type,
@@ -88,7 +88,7 @@ async def list_knowledge_ids():
if note_type and note_type not in _VALID_TYPES: if note_type and note_type not in _VALID_TYPES:
return jsonify({"error": "Invalid type"}), 400 return jsonify({"error": "Invalid type"}), 400
from scribe.services.knowledge import query_knowledge_ids from fabledassistant.services.knowledge import query_knowledge_ids
ids, total = await query_knowledge_ids( ids, total = await query_knowledge_ids(
user_id=uid, note_type=note_type, tags=tags, user_id=uid, note_type=note_type, tags=tags,
sort=sort, q=q, limit=limit, offset=offset, sort=sort, q=q, limit=limit, offset=offset,
@@ -114,7 +114,7 @@ async def get_knowledge_batch():
if len(ids) > 100: if len(ids) > 100:
return jsonify({"error": "Too many IDs (max 100)"}), 400 return jsonify({"error": "Too many IDs (max 100)"}), 400
from scribe.services.knowledge import get_knowledge_by_ids from fabledassistant.services.knowledge import get_knowledge_by_ids
items = await get_knowledge_by_ids(uid, ids) items = await get_knowledge_by_ids(uid, ids)
return jsonify({"items": items}) return jsonify({"items": items})
@@ -126,7 +126,7 @@ async def list_knowledge_tags():
uid = get_current_user_id() uid = get_current_user_id()
note_type = request.args.get("type", "").strip().lower() or None note_type = request.args.get("type", "").strip().lower() or None
from scribe.services.knowledge import get_knowledge_tags from fabledassistant.services.knowledge import get_knowledge_tags
tags = await get_knowledge_tags(uid, note_type=note_type) tags = await get_knowledge_tags(uid, note_type=note_type)
return jsonify({"tags": tags}) return jsonify({"tags": tags})
@@ -139,6 +139,6 @@ async def get_knowledge_counts():
tags_raw = request.args.get("tags", "").strip() tags_raw = request.args.get("tags", "").strip()
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else None tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else None
from scribe.services.knowledge import get_knowledge_counts as _counts from fabledassistant.services.knowledge import get_knowledge_counts as _counts
counts = await _counts(uid, tags=tags) counts = await _counts(uid, tags=tags)
return jsonify(counts) return jsonify(counts)
@@ -3,10 +3,10 @@ import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_pagination from fabledassistant.routes.utils import not_found, parse_pagination
from scribe.services.access import can_write_project from fabledassistant.services.access import can_write_project
from scribe.services.milestones import ( from fabledassistant.services.milestones import (
create_milestone, create_milestone,
delete_milestone, delete_milestone,
get_milestone_in_project, get_milestone_in_project,
@@ -14,8 +14,8 @@ from scribe.services.milestones import (
list_milestones, list_milestones,
update_milestone, update_milestone,
) )
from scribe.services.notes import list_notes from fabledassistant.services.notes import list_notes
from scribe.services.projects import get_project_for_user from fabledassistant.services.projects import get_project_for_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -113,7 +113,7 @@ async def delete_milestone_route(project_id: int, milestone_id: int):
milestone = await get_milestone_in_project(project_id, milestone_id) milestone = await get_milestone_in_project(project_id, milestone_id)
if milestone is None: if milestone is None:
return not_found("Milestone") return not_found("Milestone")
from scribe.services.trash import delete as trash_delete from fabledassistant.services.trash import delete as trash_delete
batch = await trash_delete(milestone.user_id, "milestone", milestone_id) batch = await trash_delete(milestone.user_id, "milestone", milestone_id)
if batch is None: if batch is None:
return not_found("Milestone") return not_found("Milestone")
@@ -2,18 +2,18 @@ import asyncio
import logging import logging
import re import re
from scribe.services.embeddings import upsert_note_embedding from fabledassistant.services.embeddings import upsert_note_embedding
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
from scribe.services.access import can_write_note from fabledassistant.services.notes import (
from scribe.services.notes import (
build_note_graph, build_note_graph,
convert_note_to_task, convert_note_to_task,
convert_task_to_note, convert_task_to_note,
create_note, create_note,
delete_note,
get_all_tags, get_all_tags,
get_backlinks, get_backlinks,
get_note, get_note,
@@ -23,8 +23,8 @@ from scribe.services.notes import (
list_notes, list_notes,
update_note, update_note,
) )
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft from fabledassistant.services.note_drafts import upsert_draft, get_draft, delete_draft
from scribe.services.note_versions import list_versions, get_version from fabledassistant.services.note_versions import list_versions, get_version
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -89,7 +89,7 @@ async def create_note_route():
project_id = data.get("project_id") project_id = data.get("project_id")
if project_id is None and data.get("project"): if project_id is None and data.get("project"):
from scribe.services.projects import get_project_by_title as _gpbt from fabledassistant.services.projects import get_project_by_title as _gpbt
proj = await _gpbt(uid, data["project"]) proj = await _gpbt(uid, data["project"])
if proj: if proj:
project_id = proj.id project_id = proj.id
@@ -192,15 +192,6 @@ async def get_note_route(note_id: int):
@login_required @login_required
async def update_note_route(note_id: int): async def update_note_route(note_id: int):
uid = get_current_user_id() uid = get_current_user_id()
# Share-aware: resolve through the ACL and write as the OWNER, so a shared
# editor's save isn't rejected by the owner-scoped update service.
result = await get_note_for_user(uid, note_id)
if result is None:
return not_found("Note")
note_obj, _ = result
if not await can_write_note(uid, note_id):
return jsonify({"error": "Permission denied"}), 403
owner_uid = note_obj.user_id
data = await request.get_json() data = await request.get_json()
fields = {} fields = {}
for key in ("title", "body", "description", "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"):
@@ -221,14 +212,14 @@ async def update_note_route(note_id: int):
if "tags" in data: if "tags" in data:
fields["tags"] = data["tags"] fields["tags"] = data["tags"]
try: try:
note = await update_note(owner_uid, note_id, **fields) note = await update_note(uid, note_id, **fields)
except ValueError as e: except ValueError as e:
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
if note is None: if note is None:
return not_found("Note") return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "") text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text: if text:
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text)) asyncio.create_task(upsert_note_embedding(note.id, uid, text))
return jsonify(note.to_dict()) return jsonify(note.to_dict())
@@ -236,13 +227,6 @@ async def update_note_route(note_id: int):
@login_required @login_required
async def patch_note_route(note_id: int): async def patch_note_route(note_id: int):
uid = get_current_user_id() uid = get_current_user_id()
result = await get_note_for_user(uid, note_id)
if result is None:
return not_found("Note")
note_obj, _ = result
if not await can_write_note(uid, note_id):
return jsonify({"error": "Permission denied"}), 403
owner_uid = note_obj.user_id
data = await request.get_json() data = await request.get_json()
fields = {} fields = {}
for key in ("title", "body", "description", "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"):
@@ -261,14 +245,14 @@ async def patch_note_route(note_id: int):
if "tags" in data: if "tags" in data:
fields["tags"] = data["tags"] fields["tags"] = data["tags"]
try: try:
note = await update_note(owner_uid, note_id, **fields) note = await update_note(uid, note_id, **fields)
except ValueError as e: except ValueError as e:
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
if note is None: if note is None:
return not_found("Note") return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "") text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text: if text:
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text)) asyncio.create_task(upsert_note_embedding(note.id, uid, text))
return jsonify(note.to_dict()) return jsonify(note.to_dict())
@@ -276,14 +260,8 @@ async def patch_note_route(note_id: int):
@login_required @login_required
async def delete_note_route(note_id: int): async def delete_note_route(note_id: int):
uid = get_current_user_id() uid = get_current_user_id()
result = await get_note_for_user(uid, note_id) from fabledassistant.services.trash import delete as trash_delete
if result is None: batch = await trash_delete(uid, "note", note_id)
return not_found("Note")
note_obj, _ = result
if not await can_write_note(uid, note_id):
return jsonify({"error": "Permission denied"}), 403
from scribe.services.trash import delete as trash_delete
batch = await trash_delete(note_obj.user_id, "note", note_id)
if batch is None: if batch is None:
return not_found("Note") return not_found("Note")
return "", 204 return "", 204
@@ -445,7 +423,7 @@ async def pin_version_route(note_id: int, version_id: int):
label = data.get("label") label = data.get("label")
if label is not None and not isinstance(label, str): if label is not None and not isinstance(label, str):
return jsonify({"error": "label must be a string or null"}), 400 return jsonify({"error": "label must be a string or null"}), 400
from scribe.services.version_pinning import pin_version from fabledassistant.services.version_pinning import pin_version
try: try:
version = await pin_version(uid, note_id, version_id, label=label) version = await pin_version(uid, note_id, version_id, label=label)
except ValueError as e: except ValueError as e:
@@ -462,7 +440,7 @@ async def pin_version_route(note_id: int, version_id: int):
async def unpin_version_route(note_id: int, version_id: int): async def unpin_version_route(note_id: int, version_id: int):
"""Downgrade a manually-pinned version back to rolling.""" """Downgrade a manually-pinned version back to rolling."""
uid = get_current_user_id() uid = get_current_user_id()
from scribe.services.version_pinning import unpin_version from fabledassistant.services.version_pinning import unpin_version
version = await unpin_version(uid, note_id, version_id) version = await unpin_version(uid, note_id, version_id)
if version is None: if version is None:
return not_found("Version") return not_found("Version")
@@ -1,7 +1,7 @@
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required from fabledassistant.auth import get_current_user_id, login_required
from scribe.services.user_profile import ( from fabledassistant.services.user_profile import (
VALID_EXPERTISE, VALID_EXPERTISE,
VALID_STYLES, VALID_STYLES,
VALID_TONES, VALID_TONES,
@@ -4,11 +4,11 @@ import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_pagination from fabledassistant.routes.utils import not_found, parse_pagination
from scribe.services.milestones import list_milestones from fabledassistant.services.milestones import list_milestones
from scribe.services.notes import list_notes from fabledassistant.services.notes import list_notes
from scribe.services.projects import ( from fabledassistant.services.projects import (
create_project, create_project,
delete_project, delete_project,
get_project, get_project,
@@ -53,7 +53,7 @@ async def create_project_route():
return jsonify({"error": "title is required"}), 400 return jsonify({"error": "title is required"}), 400
status = data.get("status", "active") status = data.get("status", "active")
if status not in ("active", "paused", "completed", "archived"): if status not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400 return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await create_project( project = await create_project(
uid, uid,
title=data["title"], title=data["title"],
@@ -90,7 +90,7 @@ async def update_project_route(project_id: int):
allowed = {"title", "description", "goal", "status", "color"} allowed = {"title", "description", "goal", "status", "color"}
fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed} fields = {k: (v if v is not None else "") for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"): if "status" in fields and fields["status"] not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400 return jsonify({"error": "status must be 'active', 'completed', or 'archived'"}), 400
project = await update_project(uid, project_id, **fields) project = await update_project(uid, project_id, **fields)
if project is None: if project is None:
return not_found("Project") return not_found("Project")
@@ -101,7 +101,7 @@ async def update_project_route(project_id: int):
@login_required @login_required
async def delete_project_route(project_id: int): async def delete_project_route(project_id: int):
uid = get_current_user_id() uid = get_current_user_id()
from scribe.services.trash import delete as trash_delete from fabledassistant.services.trash import delete as trash_delete
batch = await trash_delete(uid, "project", project_id) batch = await trash_delete(uid, "project", project_id)
if batch is None: if batch is None:
return not_found("Project") return not_found("Project")
@@ -116,9 +116,6 @@ async def get_project_notes_route(project_id: int):
if result is None: if result is None:
return not_found("Project") return not_found("Project")
project, _ = result project, _ = result
# Use the project owner's uid so the ownership filter on notes/milestones
# matches for shared collaborators (who'd otherwise see an empty panel).
owner_uid = project.user_id or uid
# type filter: "note", "task", or None (both) # type filter: "note", "task", or None (both)
type_filter = request.args.get("type") type_filter = request.args.get("type")
@@ -131,11 +128,11 @@ async def get_project_notes_route(project_id: int):
elif type_filter == "note": elif type_filter == "note":
is_task = False is_task = False
ms_list = await list_milestones(owner_uid, project_id) ms_list = await list_milestones(uid, project_id)
milestone_ids = [m.id for m in ms_list] milestone_ids = [m.id for m in ms_list]
notes, total = await list_notes( notes, total = await list_notes(
owner_uid, uid,
is_task=is_task, is_task=is_task,
status=status_filter, status=status_filter,
project_id=project_id, project_id=project_id,

Some files were not shown because too many files have changed in this diff Show More