Compare commits

..

9 Commits

Author SHA1 Message Date
bvandeusen 51feaddcd3 Merge pull request 'feat(mcp): make Scribe reflexively recall + scope reads to the active project' (#61) 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 42s
CI & Build / Build & push image (push) Successful in 17s
2026-06-04 23:13:27 -04:00
bvandeusen e3c6124912 feat(mcp): make Scribe reflexively recall + scope reads to the active project
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 56s
The MCP surface advertised writing well but recall poorly, and project
scoping had no anchor that survived past enter_project's snapshot:

- search / list_notes dropped the project_id their services already
  support, so a scoped search was impossible — every query swept all
  projects and bled unrelated work into the session.
- The tool descriptions were mechanical ("Semantic search over the
  user's notes and tasks") with no trigger telling Claude WHEN to reach
  for them; the server instructions were all write-discipline and said
  nothing about searching before answering or starting work.

Changes:
- search, list_notes: add project_id param, wired to the service.
- search, list_notes, list_tasks: trigger-worded descriptions that push
  passing the active project's id and reserve project_id=0 for a
  deliberate cross-project sweep.
- _INSTRUCTIONS: add a 'Reach for Scribe to RECALL, not just to record'
  block — search before answering/starting, check for an existing ticket
  before create_task, scope reads to the active project (which does not
  stick on the server).

Paired with always-on rule #75 in the FabledSword-family rulebook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:43:55 -04:00
bvandeusen 964c8005d5 Merge pull request 'Package rename (fabledassistant→scribe) + pivot cleanup' (#60) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 16s
2026-06-03 16:28:10 -04:00
bvandeusen 70ab3f38c6 chore: remove pre-pivot dead code + finish Scribe rebrand (#599 t1-3)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 1m1s
- Header wordmark Fabled -> Scribe; fable:calendar-changed event ->
  scribe:calendar-changed; SettingsView CSS comment.
- Drop dead Project.auto_summary + summary_updated_at columns (migration
  0063) -- the Ollama-era summarizer is gone; model + 2 frontend types +
  projects test updated.
- Remove pivot vestiges: diagnostics _curator_busy()/curator_busy
  heartbeat field, tz BRIEFING_DAY_START_HOUR/user_briefing_date dead
  aliases, the ignored 'model' param on get_embedding (+ its test).

ruff src/ clean; CI is the gate. Part of scribe plan #599.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:16:44 -04:00
bvandeusen b255a0f90e refactor: rename package fabledassistant -> scribe (code-only)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m14s
Renames src/fabledassistant -> src/scribe and all imports, plus the
default DB name and DB user/password (fabled -> scribe) in config +
compose. 952 refs / 154 files. Reverses the old 'internal name stays
fabledassistant' convention.

Code-only: live databases are still physically named 'fabledassistant'.
Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the
DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git
host (fabledsword), MCP (fabled-git) and the image name (fabledscribe)
are intentionally unchanged.

ruff check src/ clean locally; CI (typecheck + pytest) is the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:48:35 -04:00
bvandeusen 9ddc418f5f Merge pull request 'main publishes :latest + ACL _INSTRUCTIONS guard' (#59) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 15s
2026-06-03 14:41:15 -04:00
bvandeusen 1d4c206563 ci: main publishes :latest (main is the production line)
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 18s
main pushes now move :latest (in addition to the immutable :<sha>), so a
merge to main updates production's pointer directly — no separate release
needed just to refresh :latest. The v* release tag's distinct job becomes
the dated :<version> marker (it still refreshes :latest harmlessly). Still
no :main tag. Rules 47/46 + 10/4 updated to match on both instances.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:27:53 -04:00
bvandeusen 301352f628 Merge pull request 'MCP _INSTRUCTIONS: multi-user sharing ACL guard' (#58) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 25s
2026-06-03 14:18:01 -04:00
bvandeusen d4666bea7f feat(mcp): add multi-user sharing ACL guard to _INSTRUCTIONS
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 1m15s
The ACL constraint (scope every read/mutation by owner + shares via
services/access.py) is a security-correctness invariant that should
always be loaded, and it's FabledScribe-specific — so it belongs in
Scribe's own contained _INSTRUCTIONS, not the cross-project FabledSword
rulebook. The redundant rulebook rule will be retired once this ships
to prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:47:02 -04:00
173 changed files with 1298 additions and 2437 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
POSTGRES_USER=fabled POSTGRES_USER=fabled
POSTGRES_PASSWORD=fabled POSTGRES_PASSWORD=fabled
POSTGRES_DB=fabledassistant POSTGRES_DB=scribe
SECRET_KEY=dev-secret-change-me SECRET_KEY=dev-secret-change-me
+11 -10
View File
@@ -1,15 +1,15 @@
# 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 :<sha> (no moving tag) # Push to main: typecheck + lint + test + build :latest + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha> # Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
# #
# Both dev and main are gated AND built. dev pushes move the :dev tag; main # Both dev and main are gated AND built. dev pushes move :dev; main pushes move
# pushes publish only the immutable :<sha> image — no :main tag, because # :latest — main IS the production line, so :latest tracks main's tip and there
# :latest (release-only) is the single production pointer and a :main alias # is no separate :main tag. Every push also gets an immutable :<sha> (the
# would just duplicate it. Running CI on the main merge commit is intentional: # rollback point). A v* release tag additionally publishes the dated :<version>;
# main is validated and its :<sha> image is the rollback point. The v* release # since main already moved :latest, the release tag's distinct job is that
# tag is the ONLY trigger that publishes :latest plus the immutable :<version>. # :<version> marker (it refreshes :latest too, harmlessly).
# #
# Successive pushes to the SAME ref supersede each other (see concurrency # 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 # below), so rapid pushes don't stack identical work; dev and main runs are
@@ -142,7 +142,7 @@ 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 → (sha only), # Build on dev, main, and v* tag pushes. dev → :dev, main → :latest,
# tag → :latest + :<version>; every build also gets an immutable :<sha>. # tag → :latest + :<version>; every build also gets an immutable :<sha>.
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci runs-on: python-ci
@@ -171,8 +171,9 @@ jobs:
TAGS="$TAGS,${{ env.IMAGE }}:dev" TAGS="$TAGS,${{ env.IMAGE }}:dev"
;; ;;
refs/heads/main) refs/heads/main)
# main publishes only the immutable :<sha> image (set above) — # main IS the production line: publish :latest (plus the :<sha>
# no :main tag; :latest (release-only) is the production pointer. # set above). No separate :main tag.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
BUILD_VERSION="main" BUILD_VERSION="main"
;; ;;
refs/tags/*) refs/tags/*)
+1 -1
View File
@@ -1 +1 @@
632037 1425947
+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/fabledassistant/static/ COPY --from=build-frontend /build/dist/ src/scribe/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 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.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 fabledassistant.config import Config from scribe.config import Config
from fabledassistant.models import Base from scribe.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:
@@ -0,0 +1,34 @@
"""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),
)
+7 -7
View File
@@ -2,13 +2,13 @@ services:
app: app:
image: git.fabledsword.com/bvandeusen/fabledscribe:latest image: git.fabledsword.com/bvandeusen/fabledscribe:latest
environment: environment:
DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant" DATABASE_URL: "postgresql+asyncpg://scribe:${DB_PASSWORD}@db:5432/scribe"
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:
- fabledassistant_backend - scribe_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: fabled POSTGRES_USER: scribe
POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: fabledassistant POSTGRES_DB: scribe
networks: networks:
- fabledassistant_backend - scribe_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 fabled"] test: ["CMD-SHELL", "pg_isready -U scribe"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 10 retries: 10
@@ -51,5 +51,5 @@ volumes:
pgdata: pgdata:
networks: networks:
fabledassistant_backend: scribe_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://fabled:fabled@db:5432/fabledassistant" DATABASE_URL: "postgresql+asyncpg://scribe:scribe@db:5432/scribe"
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: fabled POSTGRES_USER: scribe
POSTGRES_PASSWORD: fabled POSTGRES_PASSWORD: scribe
POSTGRES_DB: fabledassistant POSTGRES_DB: scribe
# 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 fabled"] test: ["CMD-SHELL", "pg_isready -U scribe"]
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:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}" DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-scribe}:${POSTGRES_PASSWORD:-scribe}@db:5432/${POSTGRES_DB:-scribe}"
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:-fabled} POSTGRES_USER: ${POSTGRES_USER:-scribe}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabled} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scribe}
POSTGRES_DB: ${POSTGRES_DB:-fabledassistant} POSTGRES_DB: ${POSTGRES_DB:-scribe}
# 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:-fabled}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scribe}"]
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 `fabledassistant` project to support bearer token authentication 1. **Fable API Key Feature** — additions to the main `scribe` 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 `fabledassistant`. 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 `scribe`. 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 `fabledassistant/fable-mcp/` 1. Implement and test within `scribe/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 `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. **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.
**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/fabledassistant/models/api_key.py` | `ApiKey` SQLAlchemy model | | Create | `src/scribe/models/api_key.py` | `ApiKey` SQLAlchemy model |
| Modify | `src/fabledassistant/models/__init__.py` | Export `ApiKey` | | Modify | `src/scribe/models/__init__.py` | Export `ApiKey` |
| Create | `src/fabledassistant/services/api_keys.py` | create/list/revoke/lookup service functions | | Create | `src/scribe/services/api_keys.py` | create/list/revoke/lookup service functions |
| Modify | `src/fabledassistant/auth.py` | Add bearer token check before session fallback | | Modify | `src/scribe/auth.py` | Add bearer token check before session fallback |
| Create | `src/fabledassistant/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint | | Create | `src/scribe/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint |
| Modify | `src/fabledassistant/app.py` | Register `api_keys_bp` and `search_bp` | | Modify | `src/scribe/app.py` | Register `api_keys_bp` and `search_bp` |
| Modify | `src/fabledassistant/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` | | Modify | `src/scribe/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` |
| Modify | `src/fabledassistant/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` | | Modify | `src/scribe/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` |
| Modify | `src/fabledassistant/routes/chat.py:73-79` | Pass `conversation_type` from POST body | | Modify | `src/scribe/routes/chat.py:73-79` | Pass `conversation_type` from POST body |
| Create | `src/fabledassistant/routes/search.py` | `GET /api/search` semantic search endpoint | | Create | `src/scribe/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/fabledassistant/models/api_key.py` - Create: `src/scribe/models/api_key.py`
- Create: `alembic/versions/0027_add_api_keys.py` - Create: `alembic/versions/0027_add_api_keys.py`
- Modify: `src/fabledassistant/models/__init__.py` - Modify: `src/scribe/models/__init__.py`
- [ ] **Step 1: Write the model** - [ ] **Step 1: Write the model**
Create `src/fabledassistant/models/api_key.py`: Create `src/scribe/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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import CreatedAtMixin from scribe.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/fabledassistant/models/__init__.py`, add after the last import line: In `src/scribe/models/__init__.py`, add after the last import line:
```python ```python
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401 from scribe.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/fabledassistant/models/api_key.py \ git add src/scribe/models/api_key.py \
src/fabledassistant/models/__init__.py \ src/scribe/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/fabledassistant/services/api_keys.py` - Create: `src/scribe/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 fabledassistant.services.api_keys import ( from scribe.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 fabledassistant.services.api_keys import _key_prefix from scribe.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("fabledassistant.services.api_keys.async_session") as mock_session_ctx: with patch("scribe.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("fabledassistant.services.api_keys.async_session") as mock_session_ctx: with patch("scribe.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/fabledassistant/services/api_keys.py`: Create `src/scribe/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 fabledassistant.models import async_session from scribe.models import async_session
from fabledassistant.models.api_key import ApiKey from scribe.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/fabledassistant/services/api_keys.py tests/test_api_keys.py git add src/scribe/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/fabledassistant/auth.py` - Modify: `src/scribe/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 fabledassistant.auth import _check_auth from scribe.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("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)) monkeypatch.setattr("scribe.auth.lookup_key", AsyncMock(return_value=fake_key))
monkeypatch.setattr("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)) monkeypatch.setattr("scribe.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 fabledassistant.auth as auth_module import scribe.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 fabledassistant.auth import _check_auth from scribe.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("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)), \ with patch("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)), \
patch("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)): patch("scribe.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/fabledassistant/auth.py` with: Replace `src/scribe/auth.py` with:
```python ```python
import functools import functools
from quart import g, jsonify, request, session from quart import g, jsonify, request, session
from fabledassistant.services.auth import get_user_by_id from scribe.services.auth import get_user_by_id
from fabledassistant.services.api_keys import lookup_key from scribe.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/fabledassistant/auth.py tests/test_api_keys.py git add src/scribe/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/fabledassistant/routes/api_keys.py` - Create: `src/scribe/routes/api_keys.py`
- Modify: `src/fabledassistant/app.py` - Modify: `src/scribe/app.py`
- [ ] **Step 1: Write the routes** - [ ] **Step 1: Write the routes**
Create `src/fabledassistant/routes/api_keys.py`: Create `src/scribe/routes/api_keys.py`:
```python ```python
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key from scribe.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/fabledassistant/app.py`, add the import alongside the other route imports: In `src/scribe/app.py`, add the import alongside the other route imports:
```python ```python
from fabledassistant.routes.api_keys import api_keys_bp from scribe.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/fabledassistant/routes/api_keys.py src/fabledassistant/app.py git add src/scribe/routes/api_keys.py src/scribe/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/fabledassistant/services/chat.py` (lines 17-30 and 123-135) - Modify: `src/scribe/services/chat.py` (lines 17-30 and 123-135)
- Modify: `src/fabledassistant/routes/chat.py` (lines 73-79) - Modify: `src/scribe/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/fabledassistant/services/chat.py`, change the function signature at line 17: In `src/scribe/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/fabledassistant/services/chat.py`, update the WHERE clause at line 130: In `src/scribe/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/fabledassistant/routes/chat.py`, update `create_conversation_route` (around line 73): In `src/scribe/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/fabledassistant/services/chat.py src/fabledassistant/routes/chat.py git add src/scribe/services/chat.py src/scribe/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/fabledassistant/routes/search.py` - Create: `src/scribe/routes/search.py`
- Modify: `src/fabledassistant/app.py` - Modify: `src/scribe/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 fabledassistant.routes.search import _content_type_to_is_task from scribe.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/fabledassistant/routes/search.py`: Create `src/scribe/routes/search.py`:
```python ```python
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.services.embeddings import semantic_search_notes from scribe.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/fabledassistant/app.py`: Add to imports in `src/scribe/app.py`:
```python ```python
from fabledassistant.routes.search import search_bp from scribe.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/fabledassistant/routes/search.py \ git add src/scribe/routes/search.py \
src/fabledassistant/app.py \ src/scribe/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/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. 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.
- [ ] **Step 4: Run tests** - [ ] **Step 4: Run tests**
+9 -9
View File
@@ -39,20 +39,20 @@
## New Backend Files ## New Backend Files
### `src/fabledassistant/services/stt.py` ### `src/scribe/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/fabledassistant/services/tts.py` ### `src/scribe/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/fabledassistant/routes/voice.py` ### `src/scribe/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/fabledassistant/app.py` ### `src/scribe/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/fabledassistant/config.py` ### `src/scribe/config.py`
- Add 4 new env var attributes - Add 4 new env var attributes
- Add validation in `validate()` - Add validation in `validate()`
### `src/fabledassistant/services/llm.py` ### `src/scribe/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/fabledassistant/services/generation_task.py` ### `src/scribe/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/fabledassistant/routes/chat.py` ### `src/scribe/routes/chat.py`
- Allow `"voice"` in `conversation_type` whitelist - Allow `"voice"` in `conversation_type` whitelist
### `src/fabledassistant/services/chat.py` ### `src/scribe/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 │
│ │ │ │
│ ┌──────────────────────┐ ┌────────────┐ │ │ ┌──────────────────────┐ ┌────────────┐ │
│ │ fabledassistant │ │ ollama │ │ │ │ scribe │ │ ollama │ │
│ │ ┌────────────────┐ │ │ │ │ │ │ ┌────────────────┐ │ │ │ │
│ │ │ Quart Server │ │ │ LLM API │ │ │ │ │ Quart Server │ │ │ LLM API │ │
│ │ │ ┌──────────┐ │ │ │ │ │ │ │ │ ┌──────────┐ │ │ │ │ │
@@ -43,7 +43,7 @@
## Project Structure ## Project Structure
``` ```
fabledassistant/ scribe/
├── 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 @@ fabledassistant/
│ ├── 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/fabledassistant/ ├── src/scribe/
│ ├── 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/fabledassistant/`) ### Backend (`src/scribe/`)
| 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/fabledassistant` | PostgreSQL async connection string | | `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/scribe` | 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/fabledassistant" | docker secret create fabled_db_url - echo "postgresql+asyncpg://fabled:strongpassword@db/scribe" | 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/fabledassistant` Images pushed to: `git.fabledsword.com/bvandeusen/scribe`
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 `fabledassistant.models` - Services: `async with async_session() as session:` — import from `scribe.models`
- No `fabledassistant.database` module - No `scribe.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": "fabledassistant-frontend", "name": "scribe-frontend",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fabledassistant-frontend", "name": "scribe-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": "fabledassistant-frontend", "name": "scribe-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
+1 -1
View File
@@ -39,7 +39,7 @@ 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">Fabled</span> <span class="brand-text">Scribe</span>
</router-link> </router-link>
<!-- Center: primary navigation (desktop) --> <!-- Center: primary navigation (desktop) -->
+2 -2
View File
@@ -168,11 +168,11 @@ function onCalendarChanged() {
onMounted(() => { onMounted(() => {
document.addEventListener("mousedown", onDocClick); document.addEventListener("mousedown", onDocClick);
document.addEventListener("fable:calendar-changed", onCalendarChanged); document.addEventListener("scribe:calendar-changed", onCalendarChanged);
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("mousedown", onDocClick); document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("fable:calendar-changed", onCalendarChanged); document.removeEventListener("scribe:calendar-changed", onCalendarChanged);
}); });
// ── Calendar callbacks ───────────────────────────────────────────────────── // ── Calendar callbacks ─────────────────────────────────────────────────────
-1
View File
@@ -22,7 +22,6 @@ 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,7 +37,6 @@ 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;
+3 -3
View File
@@ -510,7 +510,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 = `fabledassistant-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`; a.download = `scribe-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 +533,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 = `fabledassistant-${stamp}.${ext}`; a.download = `scribe-${stamp}.${ext}`;
a.click(); a.click();
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
toastStore.show("Export downloaded"); toastStore.show("Export downloaded");
@@ -3076,7 +3076,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; }
/* Fable MCP section */ /* Scribe 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 = "fabledassistant" name = "scribe"
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 fabledassistant.services import rulebooks as rb_service from scribe.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:
-42
View File
@@ -1,42 +0,0 @@
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,
)
@@ -5,30 +5,30 @@ 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 fabledassistant.config import Config from scribe.config import Config
from fabledassistant.routes.admin import admin_bp from scribe.routes.admin import admin_bp
from fabledassistant.routes.api import api from scribe.routes.api import api
from fabledassistant.routes.auth import auth_bp from scribe.routes.auth import auth_bp
from fabledassistant.routes.export import export_bp from scribe.routes.export import export_bp
from fabledassistant.routes.notes import notes_bp from scribe.routes.notes import notes_bp
from fabledassistant.routes.milestones import milestones_bp from scribe.routes.milestones import milestones_bp
from fabledassistant.routes.task_logs import task_logs_bp from scribe.routes.task_logs import task_logs_bp
from fabledassistant.routes.projects import projects_bp from scribe.routes.projects import projects_bp
from fabledassistant.routes.settings import settings_bp from scribe.routes.settings import settings_bp
from fabledassistant.routes.tasks import tasks_bp from scribe.routes.tasks import tasks_bp
from fabledassistant.routes.groups import groups_bp from scribe.routes.groups import groups_bp
from fabledassistant.routes.shares import shares_bp from scribe.routes.shares import shares_bp
from fabledassistant.routes.in_app_notifications import notifications_bp from scribe.routes.in_app_notifications import notifications_bp
from fabledassistant.routes.users import users_bp from scribe.routes.users import users_bp
from fabledassistant.routes.api_keys import api_keys_bp from scribe.routes.api_keys import api_keys_bp
from fabledassistant.routes.events import events_bp from scribe.routes.events import events_bp
from fabledassistant.routes.search import search_bp from scribe.routes.search import search_bp
from fabledassistant.routes.profile import profile_bp from scribe.routes.profile import profile_bp
from fabledassistant.routes.knowledge import knowledge_bp from scribe.routes.knowledge import knowledge_bp
from fabledassistant.routes.rulebooks import rulebooks_bp from scribe.routes.rulebooks import rulebooks_bp
from fabledassistant.routes.trash import trash_bp from scribe.routes.trash import trash_bp
from fabledassistant.routes.dashboard import dashboard_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__)
@@ -115,7 +115,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 fabledassistant.services.logging import log_usage from scribe.services.logging import log_usage
user = getattr(g, "user", None) user = getattr(g, "user", None)
await log_usage( await log_usage(
@@ -150,10 +150,10 @@ def create_app() -> Quart:
async def startup(): async def startup():
import asyncio import asyncio
from fabledassistant.services.auth import start_auth_token_retention_loop 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()
@@ -170,36 +170,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 fabledassistant.services.event_scheduler import start_event_scheduler from scribe.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 fabledassistant.services.version_pinning_scheduler import ( from scribe.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 fabledassistant.services.trash_scheduler import start_trash_scheduler from scribe.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 fabledassistant.services.diagnostics import start_diagnostics from scribe.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 fabledassistant.services.event_scheduler import stop_event_scheduler from scribe.services.event_scheduler import stop_event_scheduler
stop_event_scheduler() stop_event_scheduler()
from fabledassistant.services.version_pinning_scheduler import ( from scribe.services.version_pinning_scheduler import (
stop_version_pinning_scheduler, stop_version_pinning_scheduler,
) )
stop_version_pinning_scheduler() stop_version_pinning_scheduler()
from fabledassistant.services.trash_scheduler import stop_trash_scheduler from scribe.services.trash_scheduler import stop_trash_scheduler
stop_trash_scheduler() stop_trash_scheduler()
from fabledassistant.services.diagnostics import stop_diagnostics from scribe.services.diagnostics import stop_diagnostics
stop_diagnostics() stop_diagnostics()
@app.route("/") @app.route("/")
@@ -249,7 +249,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 fabledassistant.services.logging import log_error from scribe.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 fabledassistant.services.auth import get_user_by_id from scribe.services.auth import get_user_by_id
from fabledassistant.services.api_keys import lookup_key from scribe.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://fabled:fabled@localhost:5432/fabledassistant", "postgresql+asyncpg://scribe:scribe@localhost:5432/scribe",
) )
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 fabledassistant.mcp.server import build_mcp_server, mount_mcp from scribe.mcp.server import build_mcp_server, mount_mcp
__all__ = ["build_mcp_server", "mount_mcp"] __all__ = ["build_mcp_server", "mount_mcp"]
@@ -1,7 +1,7 @@
"""MCP-side Bearer token resolution. Reuses the existing api_keys infrastructure.""" """MCP-side Bearer token resolution. Reuses the existing api_keys infrastructure."""
from __future__ import annotations from __future__ import annotations
from fabledassistant.services.api_keys import lookup_key from scribe.services.api_keys import lookup_key
async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None: async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
@@ -39,6 +39,22 @@ Mechanics:
"not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1 "not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1
removes the task from its milestone); 0 leaves it unchanged. removes the task from its milestone); 0 leaves it unchanged.
Reach for Scribe to RECALL, not just to record. Scribe is a second brain
its value is mostly in what it already holds, so make searching it a reflex,
not something you wait to be asked for:
- Before you answer a question about the user's work, or start a task, search
Scribe first (search / list_tasks / list_notes). Assume relevant prior art
already exists a related ticket, an earlier decision, a dev-log and look
before you re-derive it or open a duplicate.
- Before creating a task, search for an existing one (search content_type=
'task') don't open a second ticket for work already tracked.
- Scope reads to the project in scope. When a project is active (you called
enter_project), pass its project_id to search / list_tasks / list_notes so
results stay inside that project. Querying with no project_id pulls in every
project and bleeds unrelated work into the session only do it for a
deliberate cross-project sweep. The active project does not stick on the
server (each call is self-contained); carrying its id forward is on you.
Keep task state honest this is what makes the project a trustworthy record: Keep task state honest this is what makes the project a trustworthy record:
- When you begin working a task, set it to in_progress (update_task - When you begin working a task, set it to in_progress (update_task
status=in_progress). status=in_progress).
@@ -122,6 +138,13 @@ X process" or otherwise references a saved process, call list_processes() /
get_process(name) and follow the returned prompt verbatim, including any get_process(name) and follow the returned prompt verbatim, including any
"clarify first" steps it contains. Author a new one with create_process(title, "clarify first" steps it contains. Author a new one with create_process(title,
body); edit with update_process. body); edit with update_process.
When you are developing Scribe itself (not just using it as a data store),
honor the multi-user sharing ACL: every read or mutation of user data must
scope by owner + direct shares + group shares through services/access.py
(can_read_* / can_write_* / can_admin_*) never assume a single operator. An
unscoped query (a fetch-by-id with no ownership check) is a cross-user data
leak; "works for one user" is not done.
""" """
@@ -213,7 +236,7 @@ def build_mcp_server() -> FastMCP:
enable_dns_rebinding_protection=False, enable_dns_rebinding_protection=False,
), ),
) )
from fabledassistant.mcp.tools import register_all from scribe.mcp.tools import register_all
register_all(mcp) register_all(mcp)
return mcp return mcp
@@ -232,7 +255,7 @@ def mount_mcp(app: Quart) -> None:
inside Quart, we hook the session manager's `run()` async context manager inside Quart, we hook the session manager's `run()` async context manager
into Quart's serving lifecycle (before_serving / after_serving). into Quart's serving lifecycle (before_serving / after_serving).
""" """
from fabledassistant.mcp.auth import resolve_bearer from scribe.mcp.auth import resolve_bearer
mcp = build_mcp_server() mcp = build_mcp_server()
mcp_asgi = mcp.streamable_http_app() mcp_asgi = mcp.streamable_http_app()
@@ -292,7 +315,7 @@ def mount_mcp(app: Quart) -> None:
return return
scope["scribe_user_id"] = user_id scope["scribe_user_id"] = user_id
from fabledassistant.mcp._context import _user_id_ctx from scribe.mcp._context import _user_id_ctx
token = _user_id_ctx.set(user_id) token = _user_id_ctx.set(user_id)
try: try:
await mcp_asgi(scope, receive, send) await mcp_asgi(scope, receive, send)
@@ -4,7 +4,7 @@ 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 fabledassistant.mcp.tools import ( from scribe.mcp.tools import (
entities, events, milestones, notes, processes, projects, recent, rulebooks, search, tags, tasks, trash, entities, events, milestones, notes, processes, projects, recent, rulebooks, search, tags, tasks, trash,
) )
@@ -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 fabledassistant.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from fabledassistant.services import knowledge as knowledge_svc from scribe.services import knowledge as knowledge_svc
from fabledassistant.services import notes as notes_svc from scribe.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 fabledassistant.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from fabledassistant.services import events as events_svc from scribe.services import events as events_svc
from fabledassistant.services import trash as trash_svc from scribe.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 fabledassistant.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from fabledassistant.services import milestones as milestones_svc from scribe.services import milestones as milestones_svc
from fabledassistant.services import trash as trash_svc from scribe.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 fabledassistant.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from fabledassistant.services import notes as notes_svc from scribe.services import notes as notes_svc
from fabledassistant.services import trash as trash_svc from scribe.services import trash as trash_svc
async def list_notes( async def list_notes(
@@ -23,6 +23,7 @@ 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.
@@ -30,6 +31,12 @@ 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(
@@ -37,6 +44,7 @@ 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),
) )
@@ -6,9 +6,9 @@ get_process is the fire mechanism: it returns the full prompt for Claude to run.
""" """
from __future__ import annotations from __future__ import annotations
from fabledassistant.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from fabledassistant.services import knowledge as knowledge_svc from scribe.services import knowledge as knowledge_svc
from fabledassistant.services import notes as notes_svc from scribe.services import notes as notes_svc
async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict: async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
@@ -16,12 +16,12 @@ keeps working.
""" """
from __future__ import annotations from __future__ import annotations
from fabledassistant.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from fabledassistant.services import milestones as milestones_svc from scribe.services import milestones as milestones_svc
from fabledassistant.services import notes as notes_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:
@@ -13,11 +13,11 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
from fabledassistant.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from fabledassistant.models import async_session from scribe.models import async_session
from fabledassistant.models.event import Event from scribe.models.event import Event
from fabledassistant.models.note import Note from scribe.models.note import Note
from fabledassistant.models.project import Project from scribe.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:
@@ -9,9 +9,9 @@ spec.
""" """
from __future__ import annotations from __future__ import annotations
from fabledassistant.mcp._context import current_user_id from scribe.mcp._context import current_user_id
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
# ── Rulebook CRUD ─────────────────────────────────────────────────────── # ── Rulebook CRUD ───────────────────────────────────────────────────────
@@ -7,17 +7,34 @@ working. Differences from fable-mcp:
""" """
from __future__ import annotations from __future__ import annotations
from fabledassistant.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from fabledassistant.services.embeddings import semantic_search_notes from scribe.services.embeddings import semantic_search_notes
async def search(q: str, content_type: str = "all", limit: int = 10) -> dict: async def search(
"""Semantic search over the user's notes and tasks. q: str,
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"}],
@@ -28,6 +45,7 @@ async def search(q: str, content_type: str = "all", limit: int = 10) -> dict:
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 fabledassistant.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from fabledassistant.models import async_session from scribe.models import async_session
from fabledassistant.models.note import Note from scribe.models.note import Note
def _aggregate_tag_counts(tag_lists) -> dict[str, int]: def _aggregate_tag_counts(tag_lists) -> dict[str, int]:
@@ -18,12 +18,12 @@ Sentinels (preserved from existing fable-mcp):
""" """
from __future__ import annotations from __future__ import annotations
from fabledassistant.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from fabledassistant.services import notes as notes_svc from scribe.services import notes as notes_svc
from fabledassistant.services import planning as planning_svc from scribe.services import planning as planning_svc
from fabledassistant.services import rulebooks as rulebooks_svc from scribe.services import rulebooks as rulebooks_svc
from fabledassistant.services import task_logs as task_logs_svc from scribe.services import task_logs as task_logs_svc
from fabledassistant.services import trash as trash_svc from scribe.services import trash as trash_svc
async def list_tasks( async def list_tasks(
@@ -37,7 +37,10 @@ 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. Use 0 for no filter. project_id: Filter to a specific project. PASS THE ACTIVE PROJECT'S ID
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.
@@ -6,8 +6,8 @@ the deleted_batch_id that a delete operation returns.
""" """
from __future__ import annotations from __future__ import annotations
from fabledassistant.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from fabledassistant.services import trash as trash_svc from scribe.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 scribe.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 scribe.models.base import CreatedAtMixin, TimestampMixin # noqa: E402, F401
from scribe.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
from scribe.models.setting import Setting # noqa: E402, F401
from scribe.models.user import User # noqa: E402, F401
from scribe.models.app_log import AppLog # noqa: E402, F401
from scribe.models.password_reset import PasswordResetToken # noqa: E402, F401
from scribe.models.invitation import InvitationToken # noqa: E402, F401
from scribe.models.embedding import NoteEmbedding # noqa: E402, F401
from scribe.models.project import Project # noqa: E402, F401
from scribe.models.event import Event # noqa: E402, F401
from scribe.models.milestone import Milestone # noqa: E402, F401
from scribe.models.task_log import TaskLog # noqa: E402, F401
from scribe.models.note_draft import NoteDraft # noqa: E402, F401
from scribe.models.note_version import NoteVersion # noqa: E402, F401
from scribe.models.group import Group, GroupMembership # noqa: E402, F401
from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
from scribe.models.notification import Notification # noqa: E402, F401
from scribe.models.api_key import ApiKey # noqa: E402, F401
from scribe.models.user_profile import UserProfile # noqa: E402, F401
from scribe.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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import CreatedAtMixin from scribe.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 fabledassistant.models import Base from scribe.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 fabledassistant.models import Base from scribe.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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import SoftDeleteMixin from scribe.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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin from scribe.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 fabledassistant.models import Base from scribe.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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin from scribe.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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin from scribe.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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import TimestampMixin from scribe.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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import CreatedAtMixin from scribe.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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import CreatedAtMixin from scribe.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 fabledassistant.models import Base from scribe.models import Base
class PasswordResetToken(Base): class PasswordResetToken(Base):
@@ -1,9 +1,8 @@
import enum import enum
from datetime import datetime from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin from scribe.models.base import TimestampMixin, SoftDeleteMixin
class ProjectStatus(str, enum.Enum): class ProjectStatus(str, enum.Enum):
@@ -22,10 +21,6 @@ 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 {
@@ -36,7 +31,6 @@ 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(),
} }
@@ -3,8 +3,8 @@ from datetime import datetime, timezone
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, Text, text from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import SoftDeleteMixin from scribe.models.base import SoftDeleteMixin
class Rulebook(Base, SoftDeleteMixin): class Rulebook(Base, SoftDeleteMixin):
@@ -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 fabledassistant.models import Base from scribe.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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import TimestampMixin from scribe.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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import TimestampMixin from scribe.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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin
class User(Base, CreatedAtMixin): class User(Base, CreatedAtMixin):
@@ -2,8 +2,8 @@ from sqlalchemy import 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 fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import TimestampMixin from scribe.models.base import TimestampMixin
class UserProfile(Base, TimestampMixin): class UserProfile(Base, TimestampMixin):
@@ -3,8 +3,8 @@ import json
from quart import Blueprint, Response, g, jsonify, request from quart import Blueprint, Response, g, jsonify, request
from fabledassistant.auth import admin_required, login_required, get_current_user_id from scribe.auth import admin_required, login_required, get_current_user_id
from fabledassistant.services.auth import ( from scribe.services.auth import (
create_invitation, create_invitation,
delete_user, delete_user,
is_registration_open, is_registration_open,
@@ -13,15 +13,15 @@ from fabledassistant.services.auth import (
revoke_invitation, revoke_invitation,
set_registration_open, set_registration_open,
) )
from fabledassistant.services.backup import ( from scribe.services.backup import (
export_full_backup, export_full_backup,
export_user_backup, export_user_backup,
restore_full_backup, restore_full_backup,
) )
from fabledassistant.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from fabledassistant.services.logging import get_logs, get_log_stats, log_audit from scribe.services.logging import get_logs, get_log_stats, log_audit
from fabledassistant.services.notifications import send_invitation_email from scribe.services.notifications import send_invitation_email
from fabledassistant.services.settings import set_setting, set_settings_batch from scribe.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 fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key from scribe.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 fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.config import Config from scribe.config import Config
from fabledassistant.rate_limit import is_rate_limited from scribe.rate_limit import is_rate_limited
from fabledassistant.services.auth import ( from scribe.services.auth import (
authenticate, authenticate,
change_password, change_password,
create_password_reset_token, create_password_reset_token,
@@ -26,14 +26,14 @@ from fabledassistant.services.auth import (
validate_invitation_token, validate_invitation_token,
verify_password, verify_password,
) )
from fabledassistant.services.logging import log_audit from scribe.services.logging import log_audit
from fabledassistant.services.notifications import ( from scribe.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 fabledassistant.services.email import get_base_url, is_smtp_configured from scribe.services.email import get_base_url, is_smtp_configured
from fabledassistant.services.oauth import ( from scribe.services.oauth import (
build_auth_url, build_auth_url,
exchange_code, exchange_code,
get_userinfo, get_userinfo,
@@ -1,8 +1,8 @@
"""Dashboard REST endpoint — the aggregated landing payload.""" """Dashboard REST endpoint — the aggregated landing payload."""
from quart import Blueprint, g, jsonify from quart import Blueprint, g, jsonify
from fabledassistant.auth import login_required from scribe.auth import login_required
from fabledassistant.services.dashboard import build_dashboard from scribe.services.dashboard import build_dashboard
dashboard_bp = Blueprint("dashboard", __name__, url_prefix="/api/dashboard") dashboard_bp = Blueprint("dashboard", __name__, url_prefix="/api/dashboard")
@@ -5,8 +5,8 @@ from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from fabledassistant.auth import login_required from scribe.auth import login_required
import fabledassistant.services.events as events_svc import scribe.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 fabledassistant.services.trash import delete as trash_delete from scribe.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 fabledassistant.services.caldav_sync import sync_user_events from scribe.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 fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.models import async_session from scribe.models import async_session
from fabledassistant.models.note import Note from scribe.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="fabledassistant-{stamp}.json"', "Content-Disposition": f'attachment; filename="scribe-{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="fabledassistant-{stamp}.zip"', "Content-Disposition": f'attachment; filename="scribe-{stamp}.zip"',
}, },
) )
@@ -2,9 +2,9 @@ import asyncio
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required from scribe.auth import get_current_user_id, login_required
from fabledassistant.services import groups as group_svc from scribe.services import groups as group_svc
from fabledassistant.services.notifications import notify_group_added from scribe.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 fabledassistant.auth import get_current_user_id, login_required from scribe.auth import get_current_user_id, login_required
from fabledassistant.services.notifications import ( from scribe.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,8 +3,8 @@ import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required from scribe.auth import get_current_user_id, login_required
from fabledassistant.routes.utils import parse_pagination from scribe.routes.utils import parse_pagination
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -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 fabledassistant.services.knowledge import query_knowledge from scribe.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 fabledassistant.services.knowledge import query_knowledge_ids from scribe.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 fabledassistant.services.knowledge import get_knowledge_by_ids from scribe.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 fabledassistant.services.knowledge import get_knowledge_tags from scribe.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 fabledassistant.services.knowledge import get_knowledge_counts as _counts from scribe.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 fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_pagination from scribe.routes.utils import not_found, parse_pagination
from fabledassistant.services.access import can_write_project from scribe.services.access import can_write_project
from fabledassistant.services.milestones import ( from scribe.services.milestones import (
create_milestone, create_milestone,
delete_milestone, delete_milestone,
get_milestone_in_project, get_milestone_in_project,
@@ -14,8 +14,8 @@ from fabledassistant.services.milestones import (
list_milestones, list_milestones,
update_milestone, update_milestone,
) )
from fabledassistant.services.notes import list_notes from scribe.services.notes import list_notes
from fabledassistant.services.projects import get_project_for_user from scribe.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 fabledassistant.services.trash import delete as trash_delete from scribe.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,14 +2,14 @@ import asyncio
import logging import logging
import re import re
from fabledassistant.services.embeddings import upsert_note_embedding from scribe.services.embeddings import upsert_note_embedding
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
from fabledassistant.services.access import can_write_note 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,
@@ -23,8 +23,8 @@ from fabledassistant.services.notes import (
list_notes, list_notes,
update_note, update_note,
) )
from fabledassistant.services.note_drafts import upsert_draft, get_draft, delete_draft from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
from fabledassistant.services.note_versions import list_versions, get_version from scribe.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 fabledassistant.services.projects import get_project_by_title as _gpbt from scribe.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
@@ -282,7 +282,7 @@ async def delete_note_route(note_id: int):
note_obj, _ = result note_obj, _ = result
if not await can_write_note(uid, note_id): if not await can_write_note(uid, note_id):
return jsonify({"error": "Permission denied"}), 403 return jsonify({"error": "Permission denied"}), 403
from fabledassistant.services.trash import delete as trash_delete from scribe.services.trash import delete as trash_delete
batch = await trash_delete(note_obj.user_id, "note", note_id) 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")
@@ -445,7 +445,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 fabledassistant.services.version_pinning import pin_version from scribe.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 +462,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 fabledassistant.services.version_pinning import unpin_version from scribe.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 fabledassistant.auth import get_current_user_id, login_required from scribe.auth import get_current_user_id, login_required
from fabledassistant.services.user_profile import ( from scribe.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 fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_pagination from scribe.routes.utils import not_found, parse_pagination
from fabledassistant.services.milestones import list_milestones from scribe.services.milestones import list_milestones
from fabledassistant.services.notes import list_notes from scribe.services.notes import list_notes
from fabledassistant.services.projects import ( from scribe.services.projects import (
create_project, create_project,
delete_project, delete_project,
get_project, get_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 fabledassistant.services.trash import delete as trash_delete from scribe.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")
@@ -7,9 +7,9 @@ from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from fabledassistant.auth import login_required from scribe.auth import login_required
import fabledassistant.services.rulebooks as rulebooks_svc import scribe.services.rulebooks as rulebooks_svc
from fabledassistant.services.trash import delete as trash_delete from scribe.services.trash import delete as trash_delete
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api") rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
@@ -1,7 +1,7 @@
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.services.embeddings import semantic_search_notes from scribe.services.embeddings import semantic_search_notes
search_bp = Blueprint("search", __name__, url_prefix="/api/search") search_bp = Blueprint("search", __name__, url_prefix="/api/search")
@@ -10,10 +10,10 @@ from urllib.parse import urlparse
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.config import Config from scribe.config import Config
from fabledassistant.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection from scribe.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
from fabledassistant.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch from scribe.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -2,11 +2,11 @@ import asyncio
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required from scribe.auth import get_current_user_id, login_required
from fabledassistant.services import sharing as share_svc from scribe.services import sharing as share_svc
from fabledassistant.services.access import can_admin_project, can_write_note from scribe.services.access import can_admin_project, can_write_note
from fabledassistant.services.notifications import notify_note_shared, notify_project_shared from scribe.services.notifications import notify_note_shared, notify_project_shared
from fabledassistant.services.sharing import list_shared_with_me from scribe.services.sharing import list_shared_with_me
shares_bp = Blueprint("shares", __name__) shares_bp = Blueprint("shares", __name__)
@@ -3,8 +3,8 @@ import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required from scribe.auth import get_current_user_id, login_required
from fabledassistant.services.task_logs import create_log, delete_log, list_logs, update_log from scribe.services.task_logs import create_log, delete_log, list_logs, update_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -49,7 +49,7 @@ async def update_log_route(task_id: int, log_id: int):
if content is not None: if content is not None:
content = content.strip() or None content = content.strip() or None
from fabledassistant.services.task_logs import _UNSET from scribe.services.task_logs import _UNSET
duration_minutes = data.get("duration_minutes", _UNSET) duration_minutes = data.get("duration_minutes", _UNSET)
log = await update_log(uid, log_id, content=content, duration_minutes=duration_minutes) log = await update_log(uid, log_id, content=content, duration_minutes=duration_minutes)
@@ -3,20 +3,20 @@ from datetime import date
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus from scribe.models.note import TaskPriority, TaskStatus
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
from fabledassistant.services.access import can_write_note from scribe.services.access import can_write_note
from fabledassistant.services.embeddings import upsert_note_embedding from scribe.services.embeddings import upsert_note_embedding
from fabledassistant.services.notes import ( from scribe.services.notes import (
create_note, create_note,
get_note, get_note,
get_note_for_user, get_note_for_user,
list_notes, list_notes,
update_note, update_note,
) )
from fabledassistant.services.planning import start_planning as svc_start_planning from scribe.services.planning import start_planning as svc_start_planning
from fabledassistant.services.recurrence import calculate_next_due, validate_recurrence_rule from scribe.services.recurrence import calculate_next_due, validate_recurrence_rule
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks") tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
@@ -117,7 +117,7 @@ async def create_task_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 fabledassistant.services.projects import get_project_by_title as _gpbt from scribe.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
@@ -299,7 +299,7 @@ async def delete_task_route(task_id: int):
task_note, _ = result task_note, _ = result
if not await can_write_note(uid, task_id): if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403 return jsonify({"error": "Permission denied"}), 403
from fabledassistant.services.trash import delete as trash_delete from scribe.services.trash import delete as trash_delete
batch = await trash_delete(task_note.user_id, "task", task_id) batch = await trash_delete(task_note.user_id, "task", task_id)
if batch is None: if batch is None:
return not_found("Task") return not_found("Task")
@@ -3,8 +3,8 @@ from __future__ import annotations
from quart import Blueprint, g, jsonify from quart import Blueprint, g, jsonify
from fabledassistant.auth import login_required from scribe.auth import login_required
import fabledassistant.services.trash as trash_svc import scribe.services.trash as trash_svc
trash_bp = Blueprint("trash", __name__, url_prefix="/api/trash") trash_bp = Blueprint("trash", __name__, url_prefix="/api/trash")
@@ -1,9 +1,9 @@
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from sqlalchemy import or_, select from sqlalchemy import or_, select
from fabledassistant.auth import get_current_user_id, login_required from scribe.auth import get_current_user_id, login_required
from fabledassistant.models import async_session from scribe.models import async_session
from fabledassistant.models.user import User from scribe.models.user import User
users_bp = Blueprint("users", __name__, url_prefix="/api/users") users_bp = Blueprint("users", __name__, url_prefix="/api/users")
@@ -11,11 +11,11 @@ import logging
from sqlalchemy import select from sqlalchemy import select
from fabledassistant.models import async_session from scribe.models import async_session
from fabledassistant.models.group import GroupMembership from scribe.models.group import GroupMembership
from fabledassistant.models.note import Note from scribe.models.note import Note
from fabledassistant.models.project import Project from scribe.models.project import Project
from fabledassistant.models.share import NoteShare, ProjectShare from scribe.models.share import NoteShare, ProjectShare
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -4,8 +4,8 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from fabledassistant.models import async_session from scribe.models import async_session
from fabledassistant.models.api_key import ApiKey from scribe.models.api_key import ApiKey
def generate_key() -> str: def generate_key() -> str:
@@ -6,12 +6,12 @@ from datetime import datetime, timedelta, timezone
import bcrypt import bcrypt
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from fabledassistant.models import async_session from scribe.models import async_session
from fabledassistant.models.note import Note from scribe.models.note import Note
from fabledassistant.models.invitation import InvitationToken from scribe.models.invitation import InvitationToken
from fabledassistant.models.password_reset import PasswordResetToken from scribe.models.password_reset import PasswordResetToken
from fabledassistant.models.setting import Setting from scribe.models.setting import Setting
from fabledassistant.models.user import User from scribe.models.user import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -172,7 +172,7 @@ async def delete_user(user_id: int) -> bool:
async def set_registration_open(admin_user_id: int, open: bool) -> None: async def set_registration_open(admin_user_id: int, open: bool) -> None:
from fabledassistant.services.settings import set_setting from scribe.services.settings import set_setting
await set_setting(admin_user_id, "registration_open", "true" if open else "false") await set_setting(admin_user_id, "registration_open", "true" if open else "false")
@@ -3,15 +3,15 @@ from datetime import date, datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from fabledassistant.models import async_session from scribe.models import async_session
from fabledassistant.models.milestone import Milestone from scribe.models.milestone import Milestone
from fabledassistant.models.note import Note from scribe.models.note import Note
from fabledassistant.models.note_draft import NoteDraft from scribe.models.note_draft import NoteDraft
from fabledassistant.models.note_version import NoteVersion from scribe.models.note_version import NoteVersion
from fabledassistant.models.project import Project from scribe.models.project import Project
from fabledassistant.models.setting import Setting from scribe.models.setting import Setting
from fabledassistant.models.task_log import TaskLog from scribe.models.task_log import TaskLog
from fabledassistant.models.user import User from scribe.models.user import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -8,7 +8,7 @@ from zoneinfo import ZoneInfo
import caldav import caldav
import icalendar import icalendar
from fabledassistant.services.settings import get_all_settings from scribe.services.settings import get_all_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -194,7 +194,7 @@ async def create_event(
tz = timezone or config.get("caldav_timezone") or None tz = timezone or config.get("caldav_timezone") or None
cal = icalendar.Calendar() cal = icalendar.Calendar()
cal.add("prodid", "-//FabledAssistant//EN") cal.add("prodid", "-//Scribe//EN")
cal.add("version", "2.0") cal.add("version", "2.0")
event = icalendar.Event() event = icalendar.Event()
@@ -415,7 +415,7 @@ async def update_event(
# Rebuild ical data and save # Rebuild ical data and save
cal_data = icalendar.Calendar() cal_data = icalendar.Calendar()
cal_data.add("prodid", "-//FabledAssistant//EN") cal_data.add("prodid", "-//Scribe//EN")
cal_data.add("version", "2.0") cal_data.add("version", "2.0")
cal_data.add_component(component) cal_data.add_component(component)
event_obj.data = cal_data.to_ical().decode("utf-8") event_obj.data = cal_data.to_ical().decode("utf-8")
@@ -690,7 +690,7 @@ async def update_todo(
# Rebuild ical data and save # Rebuild ical data and save
cal_data = icalendar.Calendar() cal_data = icalendar.Calendar()
cal_data.add("prodid", "-//FabledAssistant//EN") cal_data.add("prodid", "-//Scribe//EN")
cal_data.add("version", "2.0") cal_data.add("version", "2.0")
cal_data.add_component(component) cal_data.add_component(component)
todo_obj.data = cal_data.to_ical().decode("utf-8") todo_obj.data = cal_data.to_ical().decode("utf-8")
@@ -13,8 +13,8 @@ from typing import Any
from sqlalchemy import select, update from sqlalchemy import select, update
from fabledassistant.models import async_session from scribe.models import async_session
from fabledassistant.models.event import Event from scribe.models.event import Event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -112,7 +112,7 @@ async def sync_user_events(user_id: int) -> dict:
Returns a summary dict: {created, updated, unchanged}. Returns a summary dict: {created, updated, unchanged}.
""" """
from fabledassistant.services.caldav import get_caldav_config, is_caldav_configured # noqa: PLC0415 from scribe.services.caldav import get_caldav_config, is_caldav_configured # noqa: PLC0415
if not await is_caldav_configured(user_id): if not await is_caldav_configured(user_id):
return {"skipped": True, "reason": "CalDAV not configured"} return {"skipped": True, "reason": "CalDAV not configured"}
@@ -234,7 +234,7 @@ async def sync_all_users() -> None:
"""Pull CalDAV events for all users with CalDAV configured.""" """Pull CalDAV events for all users with CalDAV configured."""
from sqlalchemy import select as sa_select # noqa: PLC0415 from sqlalchemy import select as sa_select # noqa: PLC0415
from fabledassistant.models.user import User # noqa: PLC0415 from scribe.models.user import User # noqa: PLC0415
async with async_session() as session: async with async_session() as session:
result = await session.execute(sa_select(User.id)) result = await session.execute(sa_select(User.id))
@@ -11,11 +11,11 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import case, func, select from sqlalchemy import case, func, select
from fabledassistant.models import async_session from scribe.models import async_session
from fabledassistant.models.note import Note from scribe.models.note import Note
from fabledassistant.models.project import Project from scribe.models.project import Project
from fabledassistant.models.milestone import Milestone from scribe.models.milestone import Milestone
from fabledassistant.services import milestones as milestones_svc from scribe.services import milestones as milestones_svc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -145,7 +145,7 @@ async def _recently_completed(user_id: int) -> list[dict]:
async def _upcoming_events(user_id: int) -> list[dict]: async def _upcoming_events(user_id: int) -> list[dict]:
from fabledassistant.services import events as events_svc from scribe.services import events as events_svc
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
rows = await events_svc.list_events(user_id, now, now + timedelta(days=WINDOW_DAYS)) rows = await events_svc.list_events(user_id, now, now + timedelta(days=WINDOW_DAYS))
out = [] out = []

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