Compare commits

..
14 Commits
Author SHA1 Message Date
bvandeusen 837489e4f2 Merge pull request 'CI: build on main (and drop the :main tag)' (#57) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 14s
2026-06-03 12:44:44 -04:00
bvandeusen 266af7870d Merge pull request 'MCP instruction hardening + milestone-unset' (#56) from dev into main 2026-06-03 11:19:09 -04:00
bvandeusen e3d7007417 Merge pull request 'Drift-audit remediation + Stored Processes + Dashboard' (#55) from dev into main 2026-06-03 08:11:14 -04:00
bvandeusen 0e980ee4b0 Merge pull request 'Project rule + topic suppressions' (#54) 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 48s
CI & Build / Build & push image (push) Successful in 22s
2026-06-01 08:01:59 -04:00
bvandeusen b5870d4694 Merge pull request 'Rules consolidation: Scribe-first check, project-scoped rules, enter_project handshake' (#53) from dev into main 2026-06-01 01:16:55 -04:00
bvandeusen c810d63bee Merge pull request 'MCP plan-prompt tune + Flutter docs removal' (#52) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 24s
2026-06-01 00:13:31 -04:00
bvandeusen a3a056d6fd v26.05.30.1 — MCP-first pivot + Rulebook + Plans + Soft-delete 2026-05-29 22:50:34 -04:00
bvandeusen 2414437061 Merge pull request 'feat: closeout + tool-use fixes + task-as-record + version pinning' (#50) from dev into main 2026-05-13 23:27:33 +00:00
bvandeusen 76dc75a03b Merge pull request 'feat: journal closeout for profile observations + LLM tool-use fixes' (#49) from dev into main 2026-05-13 01:31:19 +00:00
bvandeusen c8765959ea Merge pull request 'Release v26.04.29.4 — recurrence UI + journal weather wiring fixes' (#48) from dev into main 2026-04-30 02:08:19 +00:00
bvandeusen f85b92a885 Release v26.04.29.3 — Event-store data integrity + EventSlideOver redesign 2026-04-29 20:04:43 +00:00
bvandeusen b81c4aa600 Release v26.04.29.2 — Anti-placeholder rule on create_event 2026-04-29 16:10:53 +00:00
bvandeusen 404698521f Release v26.04.29.1 — Date durability + journal voice tuning 2026-04-29 14:37:45 +00:00
bvandeusen 88b351a96e Merge pull request 'Release v26.04.28.1 — Design system polish, journal config in Profile, doc cleanup' (#44) from dev into main 2026-04-28 13:12:54 +00:00
173 changed files with 2436 additions and 1297 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
POSTGRES_USER=fabled POSTGRES_USER=fabled
POSTGRES_PASSWORD=fabled POSTGRES_PASSWORD=fabled
POSTGRES_DB=scribe POSTGRES_DB=fabledassistant
SECRET_KEY=dev-secret-change-me SECRET_KEY=dev-secret-change-me
+10 -11
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 :latest + :<sha> # Push to main: typecheck + lint + test + build :<sha> (no moving tag)
# 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 :dev; main pushes move # Both dev and main are gated AND built. dev pushes move the :dev tag; main
# :latest — main IS the production line, so :latest tracks main's tip and there # pushes publish only the immutable :<sha> image — no :main tag, because
# is no separate :main tag. Every push also gets an immutable :<sha> (the # :latest (release-only) is the single production pointer and a :main alias
# rollback point). A v* release tag additionally publishes the dated :<version>; # would just duplicate it. Running CI on the main merge commit is intentional:
# since main already moved :latest, the release tag's distinct job is that # main is validated and its :<sha> image is the rollback point. The v* release
# :<version> marker (it refreshes :latest too, harmlessly). # tag is the ONLY trigger that publishes :latest plus the immutable :<version>.
# #
# 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 → :latest, # Build on dev, main, and v* tag pushes. dev → :dev, main → (sha only),
# 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,9 +171,8 @@ jobs:
TAGS="$TAGS,${{ env.IMAGE }}:dev" TAGS="$TAGS,${{ env.IMAGE }}:dev"
;; ;;
refs/heads/main) refs/heads/main)
# main IS the production line: publish :latest (plus the :<sha> # main publishes only the immutable :<sha> image (set above) —
# set above). No separate :main tag. # no :main tag; :latest (release-only) is the production pointer.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
BUILD_VERSION="main" BUILD_VERSION="main"
;; ;;
refs/tags/*) refs/tags/*)
+1 -1
View File
@@ -1 +1 @@
1425947 632037
+2 -2
View File
@@ -17,7 +17,7 @@ COPY src/ src/
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
pip install . pip install .
COPY --from=build-frontend /build/dist/ src/scribe/static/ COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
COPY alembic.ini . COPY alembic.ini .
COPY alembic/ alembic/ COPY alembic/ alembic/
@@ -30,4 +30,4 @@ ARG BUILD_VERSION=dev
ENV APP_VERSION=$BUILD_VERSION ENV APP_VERSION=$BUILD_VERSION
EXPOSE 5000 EXPOSE 5000
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] CMD ["sh", "-c", "alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
+2 -2
View File
@@ -4,8 +4,8 @@ from logging.config import fileConfig
from alembic import context from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from scribe.config import Config from fabledassistant.config import Config
from scribe.models import Base from fabledassistant.models import Base
config = context.config config = context.config
if config.config_file_name is not None: if config.config_file_name is not None:
@@ -1,34 +0,0 @@
"""drop dead Project.auto_summary columns
Revision ID: 0063
Revises: 0062
Create Date: 2026-06-03
auto_summary + summary_updated_at were written by generate_project_summary
(Ollama), removed in the MCP pivot. Nothing has populated them since; the
field was stale-or-NULL. Drop the columns.
"""
from alembic import op
import sqlalchemy as sa
revision = "0063"
down_revision = "0062"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("projects", "auto_summary")
op.drop_column("projects", "summary_updated_at")
def downgrade() -> None:
op.add_column(
"projects",
sa.Column("summary_updated_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"projects",
sa.Column("auto_summary", sa.Text(), nullable=True),
)
+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://scribe:${DB_PASSWORD}@db:5432/scribe" DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant"
SECRET_KEY: "${SECRET_KEY}" SECRET_KEY: "${SECRET_KEY}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}" LOG_LEVEL: "${LOG_LEVEL:-INFO}"
TRUST_PROXY_HEADERS: "true" TRUST_PROXY_HEADERS: "true"
SECURE_COOKIES: "true" SECURE_COOKIES: "true"
networks: networks:
- scribe_backend - fabledassistant_backend
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 30s interval: 30s
@@ -26,16 +26,16 @@ services:
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_USER: scribe POSTGRES_USER: fabled
POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: scribe POSTGRES_DB: fabledassistant
networks: networks:
- scribe_backend - fabledassistant_backend
# Lenient by design: a transient host exec/healthcheck stall (incident: # Lenient by design: a transient host exec/healthcheck stall (incident:
# runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never # runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never
# escalate to killing the DB. Health here only gates app startup order. # escalate to killing the DB. Health here only gates app startup order.
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U scribe"] test: ["CMD-SHELL", "pg_isready -U fabled"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 10 retries: 10
@@ -51,5 +51,5 @@ volumes:
pgdata: pgdata:
networks: networks:
scribe_backend: fabledassistant_backend:
driver: overlay driver: overlay
+5 -5
View File
@@ -18,7 +18,7 @@ services:
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
DATABASE_URL: "postgresql+asyncpg://scribe:scribe@db:5432/scribe" DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant"
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}" SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}" LOG_LEVEL: "${LOG_LEVEL:-INFO}"
volumes: volumes:
@@ -40,13 +40,13 @@ services:
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_USER: scribe POSTGRES_USER: fabled
POSTGRES_PASSWORD: scribe POSTGRES_PASSWORD: fabled
POSTGRES_DB: scribe POSTGRES_DB: fabledassistant
# Lenient by design: a transient host exec/healthcheck stall must never # Lenient by design: a transient host exec/healthcheck stall must never
# escalate to killing the DB. Health here only gates app startup order. # escalate to killing the DB. Health here only gates app startup order.
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U scribe"] test: ["CMD-SHELL", "pg_isready -U fabled"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 10 retries: 10
+5 -5
View File
@@ -11,7 +11,7 @@ services:
# To use a bind mount instead (gives direct host access to all app data): # To use a bind mount instead (gives direct host access to all app data):
# - ./data:/data # - ./data:/data
environment: environment:
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-scribe}:${POSTGRES_PASSWORD:-scribe}@db:5432/${POSTGRES_DB:-scribe}" DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
# Uncomment if you have a SearXNG instance you want to surface in the # Uncomment if you have a SearXNG instance you want to surface in the
# Integrations tab as a configured web-search backend: # Integrations tab as a configured web-search backend:
@@ -30,13 +30,13 @@ services:
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-scribe} POSTGRES_USER: ${POSTGRES_USER:-fabled}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scribe} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabled}
POSTGRES_DB: ${POSTGRES_DB:-scribe} POSTGRES_DB: ${POSTGRES_DB:-fabledassistant}
# Lenient by design: a transient host exec/healthcheck stall must never # Lenient by design: a transient host exec/healthcheck stall must never
# escalate to killing the DB. Health here only gates app startup order. # escalate to killing the DB. Health here only gates app startup order.
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scribe}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fabled}"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 10 retries: 10
+3 -3
View File
@@ -12,7 +12,7 @@ A Python MCP (Model Context Protocol) server that lets Claude directly interface
The work is split into two sub-projects: The work is split into two sub-projects:
1. **Fable API Key Feature** — additions to the main `scribe` project to support bearer token authentication 1. **Fable API Key Feature** — additions to the main `fabledassistant` project to support bearer token authentication
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root 2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working. A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
@@ -134,7 +134,7 @@ The MCP tool reads tokens until it receives `type: "done"`, then returns `respon
### Location ### Location
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `scribe`. It will be extracted to its own Forgejo repo once stable. `fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `fabledassistant`. It will be extracted to its own Forgejo repo once stable.
### Package Structure ### Package Structure
@@ -256,7 +256,7 @@ Claude Code spawns the process over stdio automatically. No Docker, no daemon.
## Build & Repo Plan ## Build & Repo Plan
1. Implement and test within `scribe/fable-mcp/` 1. Implement and test within `fabledassistant/fable-mcp/`
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`) 2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready 3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
+66 -66
View File
@@ -6,7 +6,7 @@
**Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done. **Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done.
**Deployment decision:** `fable-mcp/` stays permanently inside the `scribe` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance. **Deployment decision:** `fable-mcp/` stays permanently inside the `fabledassistant` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance.
**Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both. **Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both.
@@ -23,16 +23,16 @@
| Action | File | Purpose | | Action | File | Purpose |
|--------|------|---------| |--------|------|---------|
| Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table | | Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table |
| Create | `src/scribe/models/api_key.py` | `ApiKey` SQLAlchemy model | | Create | `src/fabledassistant/models/api_key.py` | `ApiKey` SQLAlchemy model |
| Modify | `src/scribe/models/__init__.py` | Export `ApiKey` | | Modify | `src/fabledassistant/models/__init__.py` | Export `ApiKey` |
| Create | `src/scribe/services/api_keys.py` | create/list/revoke/lookup service functions | | Create | `src/fabledassistant/services/api_keys.py` | create/list/revoke/lookup service functions |
| Modify | `src/scribe/auth.py` | Add bearer token check before session fallback | | Modify | `src/fabledassistant/auth.py` | Add bearer token check before session fallback |
| Create | `src/scribe/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint | | Create | `src/fabledassistant/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint |
| Modify | `src/scribe/app.py` | Register `api_keys_bp` and `search_bp` | | Modify | `src/fabledassistant/app.py` | Register `api_keys_bp` and `search_bp` |
| Modify | `src/scribe/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` | | Modify | `src/fabledassistant/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` |
| Modify | `src/scribe/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` | | Modify | `src/fabledassistant/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` |
| Modify | `src/scribe/routes/chat.py:73-79` | Pass `conversation_type` from POST body | | Modify | `src/fabledassistant/routes/chat.py:73-79` | Pass `conversation_type` from POST body |
| Create | `src/scribe/routes/search.py` | `GET /api/search` semantic search endpoint | | Create | `src/fabledassistant/routes/search.py` | `GET /api/search` semantic search endpoint |
| Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab | | Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab |
| Create | `tests/test_api_keys.py` | Unit tests for service + auth | | Create | `tests/test_api_keys.py` | Unit tests for service + auth |
| Create | `tests/test_search_route.py` | Unit test for search endpoint | | Create | `tests/test_search_route.py` | Unit test for search endpoint |
@@ -42,13 +42,13 @@
### Task 1: ApiKey model + migration ### Task 1: ApiKey model + migration
**Files:** **Files:**
- Create: `src/scribe/models/api_key.py` - Create: `src/fabledassistant/models/api_key.py`
- Create: `alembic/versions/0027_add_api_keys.py` - Create: `alembic/versions/0027_add_api_keys.py`
- Modify: `src/scribe/models/__init__.py` - Modify: `src/fabledassistant/models/__init__.py`
- [ ] **Step 1: Write the model** - [ ] **Step 1: Write the model**
Create `src/scribe/models/api_key.py`: Create `src/fabledassistant/models/api_key.py`:
```python ```python
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -56,8 +56,8 @@ from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import CreatedAtMixin from fabledassistant.models.base import CreatedAtMixin
class ApiKey(Base, CreatedAtMixin): class ApiKey(Base, CreatedAtMixin):
@@ -97,10 +97,10 @@ class ApiKey(Base, CreatedAtMixin):
- [ ] **Step 2: Export from models __init__** - [ ] **Step 2: Export from models __init__**
In `src/scribe/models/__init__.py`, add after the last import line: In `src/fabledassistant/models/__init__.py`, add after the last import line:
```python ```python
from scribe.models.api_key import ApiKey # noqa: E402, F401 from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
``` ```
- [ ] **Step 3: Write the migration** - [ ] **Step 3: Write the migration**
@@ -155,8 +155,8 @@ Expected: `Running upgrade 0026 -> 0027, add api_keys table`
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
git add src/scribe/models/api_key.py \ git add src/fabledassistant/models/api_key.py \
src/scribe/models/__init__.py \ src/fabledassistant/models/__init__.py \
alembic/versions/0027_add_api_keys.py alembic/versions/0027_add_api_keys.py
git commit -m "feat: add ApiKey model and migration 0027" git commit -m "feat: add ApiKey model and migration 0027"
``` ```
@@ -166,7 +166,7 @@ git commit -m "feat: add ApiKey model and migration 0027"
### Task 2: ApiKey service ### Task 2: ApiKey service
**Files:** **Files:**
- Create: `src/scribe/services/api_keys.py` - Create: `src/fabledassistant/services/api_keys.py`
- Create: `tests/test_api_keys.py` (service tests) - Create: `tests/test_api_keys.py` (service tests)
- [ ] **Step 1: Write the failing tests** - [ ] **Step 1: Write the failing tests**
@@ -181,7 +181,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from scribe.services.api_keys import ( from fabledassistant.services.api_keys import (
_hash_key, _hash_key,
generate_key, generate_key,
create_api_key, create_api_key,
@@ -212,7 +212,7 @@ def test_hash_key_is_sha256():
def test_generate_key_prefix(): def test_generate_key_prefix():
key = "fmcp_abcdefghijklmnop" key = "fmcp_abcdefghijklmnop"
# prefix is first 12 chars of the full key # prefix is first 12 chars of the full key
from scribe.services.api_keys import _key_prefix from fabledassistant.services.api_keys import _key_prefix
assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars
@@ -222,7 +222,7 @@ async def test_create_api_key_returns_full_key():
mock_key_obj.id = 1 mock_key_obj.id = 1
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"} mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
with patch("scribe.services.api_keys.async_session") as mock_session_ctx: with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx:
mock_session = AsyncMock() mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False) mock_session.__aexit__ = AsyncMock(return_value=False)
@@ -238,7 +238,7 @@ async def test_create_api_key_returns_full_key():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_lookup_key_returns_none_for_unknown(): async def test_lookup_key_returns_none_for_unknown():
with patch("scribe.services.api_keys.async_session") as mock_session_ctx: with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx:
mock_session = AsyncMock() mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False) mock_session.__aexit__ = AsyncMock(return_value=False)
@@ -262,7 +262,7 @@ Expected: `ImportError` or `ModuleNotFoundError` (service doesn't exist yet)
- [ ] **Step 3: Write the service** - [ ] **Step 3: Write the service**
Create `src/scribe/services/api_keys.py`: Create `src/fabledassistant/services/api_keys.py`:
```python ```python
import hashlib import hashlib
@@ -271,8 +271,8 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.api_key import ApiKey from fabledassistant.models.api_key import ApiKey
def generate_key() -> str: def generate_key() -> str:
@@ -365,7 +365,7 @@ Expected: all tests pass
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
git add src/scribe/services/api_keys.py tests/test_api_keys.py git add src/fabledassistant/services/api_keys.py tests/test_api_keys.py
git commit -m "feat: add ApiKey service with create/list/revoke/lookup" git commit -m "feat: add ApiKey service with create/list/revoke/lookup"
``` ```
@@ -374,7 +374,7 @@ git commit -m "feat: add ApiKey service with create/list/revoke/lookup"
### Task 3: Auth middleware — bearer token support ### Task 3: Auth middleware — bearer token support
**Files:** **Files:**
- Modify: `src/scribe/auth.py` - Modify: `src/fabledassistant/auth.py`
- Modify: `tests/test_api_keys.py` (add auth middleware tests) - Modify: `tests/test_api_keys.py` (add auth middleware tests)
- [ ] **Step 1: Add auth middleware tests** - [ ] **Step 1: Add auth middleware tests**
@@ -400,7 +400,7 @@ def test_scope_validation():
async def test_bearer_token_path_sets_g_user(monkeypatch): async def test_bearer_token_path_sets_g_user(monkeypatch):
"""Valid bearer token authenticates and sets g.user and g.api_key.""" """Valid bearer token authenticates and sets g.user and g.api_key."""
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from scribe.auth import _check_auth from fabledassistant.auth import _check_auth
# Mock ApiKey object # Mock ApiKey object
fake_key = MagicMock() fake_key = MagicMock()
@@ -411,8 +411,8 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
fake_user = MagicMock() fake_user = MagicMock()
fake_user.role = "user" fake_user.role = "user"
monkeypatch.setattr("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)) monkeypatch.setattr("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key))
monkeypatch.setattr("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)) monkeypatch.setattr("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user))
called_with_user = {} called_with_user = {}
@@ -434,7 +434,7 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
async with app.test_request_context("/test", method="GET", async with app.test_request_context("/test", method="GET",
headers={"Authorization": "Bearer fmcp_valid"}): headers={"Authorization": "Bearer fmcp_valid"}):
# Just verify _check_auth calls lookup_key with the right token # Just verify _check_auth calls lookup_key with the right token
import scribe.auth as auth_module import fabledassistant.auth as auth_module
auth_module.lookup_key.assert_called_with # callable auth_module.lookup_key.assert_called_with # callable
@@ -442,7 +442,7 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
async def test_read_only_key_blocked_on_post(): async def test_read_only_key_blocked_on_post():
"""Read-only API key returns 403 on non-GET requests.""" """Read-only API key returns 403 on non-GET requests."""
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from scribe.auth import _check_auth from fabledassistant.auth import _check_auth
from quart import Quart from quart import Quart
fake_key = MagicMock() fake_key = MagicMock()
@@ -459,8 +459,8 @@ async def test_read_only_key_blocked_on_post():
headers={"Authorization": "Bearer fmcp_readonly"}), headers={"Authorization": "Bearer fmcp_readonly"}),
): ):
from unittest.mock import patch from unittest.mock import patch
with patch("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)), \ with patch("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)), \
patch("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)): patch("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
async def dummy(): async def dummy():
return "ok" return "ok"
@@ -481,15 +481,15 @@ docker compose run --rm app pytest tests/test_api_keys.py -v
- [ ] **Step 3: Update auth.py** - [ ] **Step 3: Update auth.py**
Replace `src/scribe/auth.py` with: Replace `src/fabledassistant/auth.py` with:
```python ```python
import functools import functools
from quart import g, jsonify, request, session from quart import g, jsonify, request, session
from scribe.services.auth import get_user_by_id from fabledassistant.services.auth import get_user_by_id
from scribe.services.api_keys import lookup_key from fabledassistant.services.api_keys import lookup_key
def _check_auth(f, required_role: str | None = None): def _check_auth(f, required_role: str | None = None):
@@ -556,7 +556,7 @@ Expected: all existing tests still pass
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
git add src/scribe/auth.py tests/test_api_keys.py git add src/fabledassistant/auth.py tests/test_api_keys.py
git commit -m "feat: add bearer token auth to _check_auth, falls back to session" git commit -m "feat: add bearer token auth to _check_auth, falls back to session"
``` ```
@@ -565,18 +565,18 @@ git commit -m "feat: add bearer token auth to _check_auth, falls back to session
### Task 4: API key routes + app registration ### Task 4: API key routes + app registration
**Files:** **Files:**
- Create: `src/scribe/routes/api_keys.py` - Create: `src/fabledassistant/routes/api_keys.py`
- Modify: `src/scribe/app.py` - Modify: `src/fabledassistant/app.py`
- [ ] **Step 1: Write the routes** - [ ] **Step 1: Write the routes**
Create `src/scribe/routes/api_keys.py`: Create `src/fabledassistant/routes/api_keys.py`:
```python ```python
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys") api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
@@ -619,10 +619,10 @@ async def revoke_key_route(key_id: int):
- [ ] **Step 2: Register in app.py** - [ ] **Step 2: Register in app.py**
In `src/scribe/app.py`, add the import alongside the other route imports: In `src/fabledassistant/app.py`, add the import alongside the other route imports:
```python ```python
from scribe.routes.api_keys import api_keys_bp from fabledassistant.routes.api_keys import api_keys_bp
``` ```
And add the registration line after `app.register_blueprint(users_bp)`: And add the registration line after `app.register_blueprint(users_bp)`:
@@ -651,7 +651,7 @@ curl -s http://localhost:8080/api/auth/me \
- [ ] **Step 4: Commit** - [ ] **Step 4: Commit**
```bash ```bash
git add src/scribe/routes/api_keys.py src/scribe/app.py git add src/fabledassistant/routes/api_keys.py src/fabledassistant/app.py
git commit -m "feat: add API key CRUD routes and register blueprint" git commit -m "feat: add API key CRUD routes and register blueprint"
``` ```
@@ -660,14 +660,14 @@ git commit -m "feat: add API key CRUD routes and register blueprint"
### Task 5: Conversation type wiring ### Task 5: Conversation type wiring
**Files:** **Files:**
- Modify: `src/scribe/services/chat.py` (lines 17-30 and 123-135) - Modify: `src/fabledassistant/services/chat.py` (lines 17-30 and 123-135)
- Modify: `src/scribe/routes/chat.py` (lines 73-79) - Modify: `src/fabledassistant/routes/chat.py` (lines 73-79)
Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion. Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion.
- [ ] **Step 1: Update `create_conversation` service** - [ ] **Step 1: Update `create_conversation` service**
In `src/scribe/services/chat.py`, change the function signature at line 17: In `src/fabledassistant/services/chat.py`, change the function signature at line 17:
```python ```python
async def create_conversation( async def create_conversation(
@@ -692,7 +692,7 @@ async def create_conversation(
- [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"** - [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"**
In `src/scribe/services/chat.py`, update the WHERE clause at line 130: In `src/fabledassistant/services/chat.py`, update the WHERE clause at line 130:
```python ```python
result = await session.execute( result = await session.execute(
@@ -708,7 +708,7 @@ result = await session.execute(
- [ ] **Step 3: Update the POST route to accept conversation_type** - [ ] **Step 3: Update the POST route to accept conversation_type**
In `src/scribe/routes/chat.py`, update `create_conversation_route` (around line 73): In `src/fabledassistant/routes/chat.py`, update `create_conversation_route` (around line 73):
```python ```python
@chat_bp.route("/conversations", methods=["POST"]) @chat_bp.route("/conversations", methods=["POST"])
@@ -737,7 +737,7 @@ Expected: all tests pass
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
git add src/scribe/services/chat.py src/scribe/routes/chat.py git add src/fabledassistant/services/chat.py src/fabledassistant/routes/chat.py
git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep" git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep"
``` ```
@@ -746,8 +746,8 @@ git commit -m "feat: wire conversation_type through create_conversation, exclude
### Task 6: Semantic search endpoint ### Task 6: Semantic search endpoint
**Files:** **Files:**
- Create: `src/scribe/routes/search.py` - Create: `src/fabledassistant/routes/search.py`
- Modify: `src/scribe/app.py` - Modify: `src/fabledassistant/app.py`
- Create: `tests/test_search_route.py` - Create: `tests/test_search_route.py`
- [ ] **Step 1: Write the failing test** - [ ] **Step 1: Write the failing test**
@@ -762,7 +762,7 @@ from unittest.mock import patch, AsyncMock
def test_content_type_mapping(): def test_content_type_mapping():
"""Verify content_type string maps to correct is_task value.""" """Verify content_type string maps to correct is_task value."""
from scribe.routes.search import _content_type_to_is_task from fabledassistant.routes.search import _content_type_to_is_task
assert _content_type_to_is_task("note") is False assert _content_type_to_is_task("note") is False
assert _content_type_to_is_task("task") is True assert _content_type_to_is_task("task") is True
assert _content_type_to_is_task("all") is None assert _content_type_to_is_task("all") is None
@@ -779,13 +779,13 @@ Expected: `ImportError` (module doesn't exist yet)
- [ ] **Step 3: Write the route** - [ ] **Step 3: Write the route**
Create `src/scribe/routes/search.py`: Create `src/fabledassistant/routes/search.py`:
```python ```python
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.services.embeddings import semantic_search_notes from fabledassistant.services.embeddings import semantic_search_notes
search_bp = Blueprint("search", __name__, url_prefix="/api/search") search_bp = Blueprint("search", __name__, url_prefix="/api/search")
@@ -834,9 +834,9 @@ Note: check `services/embeddings.py` to confirm the return type of `semantic_sea
- [ ] **Step 4: Register in app.py** - [ ] **Step 4: Register in app.py**
Add to imports in `src/scribe/app.py`: Add to imports in `src/fabledassistant/app.py`:
```python ```python
from scribe.routes.search import search_bp from fabledassistant.routes.search import search_bp
``` ```
Add registration: Add registration:
@@ -855,8 +855,8 @@ Expected: all tests pass
- [ ] **Step 6: Commit** - [ ] **Step 6: Commit**
```bash ```bash
git add src/scribe/routes/search.py \ git add src/fabledassistant/routes/search.py \
src/scribe/app.py \ src/fabledassistant/app.py \
tests/test_search_route.py tests/test_search_route.py
git commit -m "feat: add GET /api/search semantic search endpoint" git commit -m "feat: add GET /api/search semantic search endpoint"
``` ```
@@ -1905,7 +1905,7 @@ async def send_message(
return f"Error: {e}" return f"Error: {e}"
``` ```
Note: verify the Fable message POST endpoint path by checking `src/scribe/routes/chat.py` — search for the route that accepts a user message and triggers generation. Adjust `/api/chat/conversations/{conv_id}/messages` if the actual path differs. Note: verify the Fable message POST endpoint path by checking `src/fabledassistant/routes/chat.py` — search for the route that accepts a user message and triggers generation. Adjust `/api/chat/conversations/{conv_id}/messages` if the actual path differs.
- [ ] **Step 4: Run tests** - [ ] **Step 4: Run tests**
+9 -9
View File
@@ -39,20 +39,20 @@
## New Backend Files ## New Backend Files
### `src/scribe/services/stt.py` ### `src/fabledassistant/services/stt.py`
Lazy singleton `WhisperModel` loader. Public API: Lazy singleton `WhisperModel` loader. Public API:
- `load_stt_model()` — called at startup via `asyncio.create_task` - `load_stt_model()` — called at startup via `asyncio.create_task`
- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text - `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text
- `stt_available() -> bool` - `stt_available() -> bool`
### `src/scribe/services/tts.py` ### `src/fabledassistant/services/tts.py`
Lazy singleton `KPipeline` loader. Public API: Lazy singleton `KPipeline` loader. Public API:
- `load_tts_model()` — called at startup - `load_tts_model()` — called at startup
- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono) - `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono)
- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels - `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels
- `tts_available() -> bool` - `tts_available() -> bool`
### `src/scribe/routes/voice.py` ### `src/fabledassistant/routes/voice.py`
Blueprint at `/api/voice`, all routes `@login_required`. Blueprint at `/api/voice`, all routes `@login_required`.
| Endpoint | Method | Description | | Endpoint | Method | Description |
@@ -66,27 +66,27 @@ Blueprint at `/api/voice`, all routes `@login_required`.
## Modified Backend Files ## Modified Backend Files
### `src/scribe/app.py` ### `src/fabledassistant/app.py`
- Register `voice_bp` blueprint - Register `voice_bp` blueprint
- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED` - In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED`
### `src/scribe/config.py` ### `src/fabledassistant/config.py`
- Add 4 new env var attributes - Add 4 new env var attributes
- Add validation in `validate()` - Add validation in `validate()`
### `src/scribe/services/llm.py` ### `src/fabledassistant/services/llm.py`
- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()` - Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()`
- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."* - When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."*
- Append style modifier based on `voice_speech_style` - Append style modifier based on `voice_speech_style`
### `src/scribe/services/generation_task.py` ### `src/fabledassistant/services/generation_task.py`
- Add `voice_mode: bool = False` to `run_generation()` - Add `voice_mode: bool = False` to `run_generation()`
- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()` - Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()`
### `src/scribe/routes/chat.py` ### `src/fabledassistant/routes/chat.py`
- Allow `"voice"` in `conversation_type` whitelist - Allow `"voice"` in `conversation_type` whitelist
### `src/scribe/services/chat.py` ### `src/fabledassistant/services/chat.py`
- Exclude `conversation_type == "voice"` from auto-cleanup retention - Exclude `conversation_type == "voice"` from auto-cleanup retention
--- ---
+4 -4
View File
@@ -20,7 +20,7 @@
│ Docker Compose │ │ Docker Compose │
│ │ │ │
│ ┌──────────────────────┐ ┌────────────┐ │ │ ┌──────────────────────┐ ┌────────────┐ │
│ │ scribe │ │ ollama │ │ │ │ fabledassistant │ │ ollama │ │
│ │ ┌────────────────┐ │ │ │ │ │ │ ┌────────────────┐ │ │ │ │
│ │ │ Quart Server │ │ │ LLM API │ │ │ │ │ Quart Server │ │ │ LLM API │ │
│ │ │ ┌──────────┐ │ │ │ │ │ │ │ │ ┌──────────┐ │ │ │ │ │
@@ -43,7 +43,7 @@
## Project Structure ## Project Structure
``` ```
scribe/ fabledassistant/
├── docker-compose.yml # Development stack ├── docker-compose.yml # Development stack
├── docker-compose.prod.yml # Production stack (Docker Swarm) ├── docker-compose.prod.yml # Production stack (Docker Swarm)
├── Dockerfile # Multi-stage build (Node → Python) ├── Dockerfile # Multi-stage build (Node → Python)
@@ -54,7 +54,7 @@ scribe/
│ ├── server.py # FastMCP tool registrations │ ├── server.py # FastMCP tool registrations
│ ├── client.py # FableClient (httpx wrapper) │ ├── client.py # FableClient (httpx wrapper)
│ └── tools/ # Tool modules (notes, tasks, projects, …) │ └── tools/ # Tool modules (notes, tasks, projects, …)
├── src/scribe/ ├── src/fabledassistant/
│ ├── app.py # Quart app factory + blueprint registration │ ├── app.py # Quart app factory + blueprint registration
│ ├── config.py # Config class (reads env vars) │ ├── config.py # Config class (reads env vars)
│ ├── auth.py # login_required decorator, session checks │ ├── auth.py # login_required decorator, session checks
@@ -169,7 +169,7 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
## Detailed File Reference ## Detailed File Reference
### Backend (`src/scribe/`) ### Backend (`src/fabledassistant/`)
| File | Responsibility | | File | Responsibility |
|------|---------------| |------|---------------|
+2 -2
View File
@@ -8,7 +8,7 @@ Configuration is via environment variables. The `docker-compose.yml` file sets d
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/scribe` | PostgreSQL async connection string | | `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/fabledassistant` | PostgreSQL async connection string |
| `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** | | `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** |
| `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) | | `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) |
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | | `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
@@ -91,7 +91,7 @@ The production compose file adds:
```bash ```bash
# Create Docker secrets # Create Docker secrets
echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key - echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key -
echo "postgresql+asyncpg://fabled:strongpassword@db/scribe" | docker secret create fabled_db_url - echo "postgresql+asyncpg://fabled:strongpassword@db/fabledassistant" | docker secret create fabled_db_url -
# Deploy # Deploy
docker stack deploy -c docker-compose.prod.yml fabled docker stack deploy -c docker-compose.prod.yml fabled
+3 -3
View File
@@ -93,7 +93,7 @@ config on the runner host.
### Docker Registry ### Docker Registry
Images pushed to: `git.fabledsword.com/bvandeusen/scribe` Images pushed to: `git.fabledsword.com/bvandeusen/fabledassistant`
Cache tag: `:cache` (reduces build time ~80%) Cache tag: `:cache` (reduces build time ~80%)
Required secrets (repo → Settings → Secrets → Actions): Required secrets (repo → Settings → Secrets → Actions):
@@ -140,8 +140,8 @@ Current migration sequence (all idempotent raw SQL):
### Backend ### Backend
- Services: `async with async_session() as session:` — import from `scribe.models` - Services: `async with async_session() as session:` — import from `fabledassistant.models`
- No `scribe.database` module - No `fabledassistant.database` module
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc. - Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
- All business logic in `services/`; routes are thin wrappers - All business logic in `services/`; routes are thin wrappers
- Permission checks via `services/access.py` — never inline ownership checks in routes - Permission checks via `services/access.py` — never inline ownership checks in routes
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "scribe-frontend", "name": "fabledassistant-frontend",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "scribe-frontend", "name": "fabledassistant-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fullcalendar/core": "^6.1.20", "@fullcalendar/core": "^6.1.20",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "scribe-frontend", "name": "fabledassistant-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
+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">Scribe</span> <span class="brand-text">Fabled</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("scribe:calendar-changed", onCalendarChanged); document.addEventListener("fable:calendar-changed", onCalendarChanged);
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("mousedown", onDocClick); document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("scribe:calendar-changed", onCalendarChanged); document.removeEventListener("fable:calendar-changed", onCalendarChanged);
}); });
// ── Calendar callbacks ───────────────────────────────────────────────────── // ── Calendar callbacks ─────────────────────────────────────────────────────
+1
View File
@@ -22,6 +22,7 @@ interface Project {
goal: string | null; goal: string | null;
status: "active" | "paused" | "completed" | "archived"; status: "active" | "paused" | "completed" | "archived";
color: string | null; color: string | null;
auto_summary: string | null;
permission?: string; permission?: string;
is_shared?: boolean; is_shared?: boolean;
created_at: string; created_at: string;
+1
View File
@@ -37,6 +37,7 @@ interface Project {
goal: string | null; goal: string | null;
status: "active" | "paused" | "completed" | "archived"; status: "active" | "paused" | "completed" | "archived";
color: string | null; color: string | null;
auto_summary: string | null;
permission?: string; permission?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
+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 = `scribe-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`; a.download = `fabledassistant-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
a.click(); a.click();
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
toastStore.show("Backup downloaded"); toastStore.show("Backup downloaded");
@@ -533,7 +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 = `scribe-${stamp}.${ext}`; a.download = `fabledassistant-${stamp}.${ext}`;
a.click(); a.click();
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
toastStore.show("Export downloaded"); toastStore.show("Export downloaded");
@@ -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; }
/* Scribe MCP section */ /* Fable MCP section */
.mcp-status { opacity: 0.6; font-size: 0.9rem; } .mcp-status { opacity: 0.6; font-size: 0.9rem; }
.mcp-unavailable p { opacity: 0.7; } .mcp-unavailable p { opacity: 0.7; }
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; } .mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
+1 -1
View File
@@ -3,7 +3,7 @@ requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "scribe" name = "fabledassistant"
version = "0.1.0" version = "0.1.0"
description = "Self-hosted note-taking, project-management, and calendar app exposed to Claude via MCP" description = "Self-hosted note-taking, project-management, and calendar app exposed to Claude via MCP"
requires-python = ">=3.14" requires-python = ">=3.14"
+1 -1
View File
@@ -103,7 +103,7 @@ async def run(args) -> None:
print(f" - {r['title']}: {preview}{empty_flag}") print(f" - {r['title']}: {preview}{empty_flag}")
return return
from scribe.services import rulebooks as rb_service from fabledassistant.services import rulebooks as rb_service
existing = await rb_service.find_rulebook_by_title(args.user_id, args.rulebook_title) existing = await rb_service.find_rulebook_by_title(args.user_id, args.rulebook_title)
if existing is not None and not args.force: if existing is not None and not args.force:
@@ -5,30 +5,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 scribe.config import Config from fabledassistant.config import Config
from scribe.routes.admin import admin_bp from fabledassistant.routes.admin import admin_bp
from scribe.routes.api import api from fabledassistant.routes.api import api
from scribe.routes.auth import auth_bp from fabledassistant.routes.auth import auth_bp
from scribe.routes.export import export_bp from fabledassistant.routes.export import export_bp
from scribe.routes.notes import notes_bp from fabledassistant.routes.notes import notes_bp
from scribe.routes.milestones import milestones_bp from fabledassistant.routes.milestones import milestones_bp
from scribe.routes.task_logs import task_logs_bp from fabledassistant.routes.task_logs import task_logs_bp
from scribe.routes.projects import projects_bp from fabledassistant.routes.projects import projects_bp
from scribe.routes.settings import settings_bp from fabledassistant.routes.settings import settings_bp
from scribe.routes.tasks import tasks_bp from fabledassistant.routes.tasks import tasks_bp
from scribe.routes.groups import groups_bp from fabledassistant.routes.groups import groups_bp
from scribe.routes.shares import shares_bp from fabledassistant.routes.shares import shares_bp
from scribe.routes.in_app_notifications import notifications_bp from fabledassistant.routes.in_app_notifications import notifications_bp
from scribe.routes.users import users_bp from fabledassistant.routes.users import users_bp
from scribe.routes.api_keys import api_keys_bp from fabledassistant.routes.api_keys import api_keys_bp
from scribe.routes.events import events_bp from fabledassistant.routes.events import events_bp
from scribe.routes.search import search_bp from fabledassistant.routes.search import search_bp
from scribe.routes.profile import profile_bp from fabledassistant.routes.profile import profile_bp
from scribe.routes.knowledge import knowledge_bp from fabledassistant.routes.knowledge import knowledge_bp
from scribe.routes.rulebooks import rulebooks_bp from fabledassistant.routes.rulebooks import rulebooks_bp
from scribe.routes.trash import trash_bp from fabledassistant.routes.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp from fabledassistant.routes.dashboard import dashboard_bp
from scribe.mcp import mount_mcp from fabledassistant.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 scribe.services.logging import log_usage from fabledassistant.services.logging import log_usage
user = getattr(g, "user", None) user = getattr(g, "user", None)
await log_usage( await log_usage(
@@ -150,10 +150,10 @@ def create_app() -> Quart:
async def startup(): async def startup():
import asyncio import asyncio
from scribe.services.auth import start_auth_token_retention_loop from fabledassistant.services.auth import start_auth_token_retention_loop
from scribe.services.embeddings import backfill_note_embeddings from fabledassistant.services.embeddings import backfill_note_embeddings
from scribe.services.logging import start_log_retention_loop from fabledassistant.services.logging import start_log_retention_loop
from scribe.services.notifications import start_notification_loop from fabledassistant.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 scribe.services.event_scheduler import start_event_scheduler from fabledassistant.services.event_scheduler import start_event_scheduler
start_event_scheduler(asyncio.get_running_loop()) start_event_scheduler(asyncio.get_running_loop())
# Version-pinning scheduler (daily auto-pin scan at 03:00 UTC) # Version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
from scribe.services.version_pinning_scheduler import ( from fabledassistant.services.version_pinning_scheduler import (
start_version_pinning_scheduler, start_version_pinning_scheduler,
) )
start_version_pinning_scheduler(asyncio.get_running_loop()) start_version_pinning_scheduler(asyncio.get_running_loop())
# Trash retention scheduler (daily expired-trash purge at 03:30 UTC) # Trash retention scheduler (daily expired-trash purge at 03:30 UTC)
from scribe.services.trash_scheduler import start_trash_scheduler from fabledassistant.services.trash_scheduler import start_trash_scheduler
start_trash_scheduler(asyncio.get_running_loop()) start_trash_scheduler(asyncio.get_running_loop())
# Diagnostic instrumentation — heartbeat, signal handlers, asyncio # Diagnostic instrumentation — heartbeat, signal handlers, asyncio
# exception hook. Cheap (~1 log line/min), high diagnostic value when # exception hook. Cheap (~1 log line/min), high diagnostic value when
# the app crashes mysteriously. See services/diagnostics.py. # the app crashes mysteriously. See services/diagnostics.py.
from scribe.services.diagnostics import start_diagnostics from fabledassistant.services.diagnostics import start_diagnostics
start_diagnostics(asyncio.get_running_loop()) start_diagnostics(asyncio.get_running_loop())
@app.after_serving @app.after_serving
async def shutdown(): async def shutdown():
from scribe.services.event_scheduler import stop_event_scheduler from fabledassistant.services.event_scheduler import stop_event_scheduler
stop_event_scheduler() stop_event_scheduler()
from scribe.services.version_pinning_scheduler import ( from fabledassistant.services.version_pinning_scheduler import (
stop_version_pinning_scheduler, stop_version_pinning_scheduler,
) )
stop_version_pinning_scheduler() stop_version_pinning_scheduler()
from scribe.services.trash_scheduler import stop_trash_scheduler from fabledassistant.services.trash_scheduler import stop_trash_scheduler
stop_trash_scheduler() stop_trash_scheduler()
from scribe.services.diagnostics import stop_diagnostics from fabledassistant.services.diagnostics import stop_diagnostics
stop_diagnostics() stop_diagnostics()
@app.route("/") @app.route("/")
@@ -249,7 +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 scribe.services.logging import log_error from fabledassistant.services.logging import log_error
user = getattr(g, "user", None) user = getattr(g, "user", None)
await log_error( await log_error(
@@ -2,8 +2,8 @@ import functools
from quart import g, jsonify, request, session from quart import g, jsonify, request, session
from scribe.services.auth import get_user_by_id from fabledassistant.services.auth import get_user_by_id
from scribe.services.api_keys import lookup_key from fabledassistant.services.api_keys import lookup_key
def _check_auth(f, required_role: str | None = None): def _check_auth(f, required_role: str | None = None):
@@ -20,7 +20,7 @@ class Config:
DATABASE_URL: str = _read_secret( DATABASE_URL: str = _read_secret(
"DATABASE_URL", "DATABASE_URL",
"DATABASE_URL_FILE", "DATABASE_URL_FILE",
"postgresql+asyncpg://scribe:scribe@localhost:5432/scribe", "postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
) )
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me") SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes") SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
@@ -3,6 +3,6 @@
Auth uses the existing `api_keys` table: a Bearer token in the Authorization Auth uses the existing `api_keys` table: a Bearer token in the Authorization
header resolves to a user, and every tool call acts on that user's data only. header resolves to a user, and every tool call acts on that user's data only.
""" """
from scribe.mcp.server import build_mcp_server, mount_mcp from fabledassistant.mcp.server import build_mcp_server, mount_mcp
__all__ = ["build_mcp_server", "mount_mcp"] __all__ = ["build_mcp_server", "mount_mcp"]
@@ -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 scribe.services.api_keys import lookup_key from fabledassistant.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,22 +39,6 @@ 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).
@@ -138,13 +122,6 @@ 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.
""" """
@@ -236,7 +213,7 @@ def build_mcp_server() -> FastMCP:
enable_dns_rebinding_protection=False, enable_dns_rebinding_protection=False,
), ),
) )
from scribe.mcp.tools import register_all from fabledassistant.mcp.tools import register_all
register_all(mcp) register_all(mcp)
return mcp return mcp
@@ -255,7 +232,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 scribe.mcp.auth import resolve_bearer from fabledassistant.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()
@@ -315,7 +292,7 @@ def mount_mcp(app: Quart) -> None:
return return
scope["scribe_user_id"] = user_id scope["scribe_user_id"] = user_id
from scribe.mcp._context import _user_id_ctx from fabledassistant.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 scribe.mcp.tools import ( from fabledassistant.mcp.tools import (
entities, events, milestones, notes, processes, projects, recent, rulebooks, search, tags, tasks, trash, entities, events, milestones, notes, 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 scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import knowledge as knowledge_svc from fabledassistant.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc from fabledassistant.services import notes as notes_svc
async def _list_by_type(note_type: str, q: str, tag: str, limit: int) -> dict: async def _list_by_type(note_type: str, q: str, tag: str, limit: int) -> dict:
@@ -19,9 +19,9 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import events as events_svc from fabledassistant.services import events as events_svc
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
def _combine(start_date: str, start_time: str) -> datetime: def _combine(start_date: str, start_time: str) -> datetime:
@@ -11,9 +11,9 @@ Sentinels:
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import milestones as milestones_svc from fabledassistant.services import milestones as milestones_svc
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
async def list_milestones(project_id: int) -> dict: async def list_milestones(project_id: int) -> dict:
@@ -13,9 +13,9 @@ Sentinel conventions (inherited from existing fable-mcp tools):
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import notes as notes_svc from fabledassistant.services import notes as notes_svc
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
async def list_notes( async def list_notes(
@@ -23,7 +23,6 @@ async def list_notes(
offset: int = 0, offset: int = 0,
tag: str = "", tag: str = "",
search_text: str = "", search_text: str = "",
project_id: int = 0,
) -> dict: ) -> dict:
"""List notes (non-task documents) stored in Scribe. """List notes (non-task documents) stored in Scribe.
@@ -31,12 +30,6 @@ async def list_notes(
search against title and body. Results are ordered by last-updated descending. search against title and body. Results are ordered by last-updated descending.
Use search for semantic/meaning-based lookup instead of exact keyword search. Use search for semantic/meaning-based lookup instead of exact keyword search.
Args:
project_id: Scope to one project. PASS THE ACTIVE PROJECT'S ID whenever a
project is in scope so you list that project's notes, not every
project's. 0 = no filter (all projects — use only for a deliberate
cross-project view).
""" """
uid = current_user_id() uid = current_user_id()
rows, total = await notes_svc.list_notes( rows, total = await notes_svc.list_notes(
@@ -44,7 +37,6 @@ async def list_notes(
q=search_text or None, q=search_text or None,
tags=[tag] if tag else None, tags=[tag] if tag else None,
is_task=False, is_task=False,
project_id=project_id or None,
limit=max(1, min(limit, 100)), limit=max(1, min(limit, 100)),
offset=max(0, offset), offset=max(0, offset),
) )
@@ -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 scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import knowledge as knowledge_svc from fabledassistant.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc from fabledassistant.services import notes as notes_svc
async def list_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 scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import milestones as milestones_svc from fabledassistant.services import milestones as milestones_svc
from scribe.services import notes as notes_svc from fabledassistant.services import notes as notes_svc
from scribe.services import projects as projects_svc from fabledassistant.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc from fabledassistant.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
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 scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.event import Event from fabledassistant.models.event import Event
from scribe.models.note import Note from fabledassistant.models.note import Note
from scribe.models.project import Project from fabledassistant.models.project import Project
async def get_recent(days: int = 7, limit: int = 25) -> dict: async def get_recent(days: int = 7, limit: int = 25) -> dict:
@@ -9,9 +9,9 @@ spec.
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import rulebooks as rulebooks_svc from fabledassistant.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
# ── Rulebook CRUD ─────────────────────────────────────────────────────── # ── Rulebook CRUD ───────────────────────────────────────────────────────
@@ -7,34 +7,17 @@ working. Differences from fable-mcp:
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services.embeddings import semantic_search_notes from fabledassistant.services.embeddings import semantic_search_notes
async def search( async def search(q: str, content_type: str = "all", limit: int = 10) -> dict:
q: str, """Semantic search over the user's notes and tasks.
content_type: str = "all",
limit: int = 10,
project_id: int = 0,
) -> dict:
"""Semantic search over the user's existing notes and tasks — Scribe's recall.
Reach for this BEFORE answering a question about the user's work or starting
a task: the user's second-brain almost always already holds related prior
art. Check for an existing ticket before opening a new one (search with
content_type='task'), and for prior notes/decisions before re-deriving them.
Treating Scribe as the first place to look not a place to only write is
the difference between it being a trustworthy record and a write-only log.
Args: Args:
q: search query string. q: search query string.
content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only). content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only).
limit: maximum number of results (1-50). limit: maximum number of results (1-50).
project_id: Scope results to one project. PASS THE ACTIVE PROJECT'S ID
whenever a project is in scope (the one you entered with
enter_project) otherwise this searches across ALL projects and
bleeds unrelated work into the result set. 0 = search everything
(use only when you genuinely want a cross-project sweep).
Returns: Returns:
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}], {"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
@@ -45,7 +28,6 @@ async def search(
is_task = {"note": False, "task": True}.get(content_type) # None => any is_task = {"note": False, "task": True}.get(content_type) # None => any
raw = await semantic_search_notes( raw = await semantic_search_notes(
uid, q, limit=limit, is_task=is_task, uid, q, limit=limit, is_task=is_task,
project_id=project_id or None,
) )
return { return {
"results": [ "results": [
@@ -8,9 +8,9 @@ from __future__ import annotations
from sqlalchemy import select from sqlalchemy import select
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.note import Note from fabledassistant.models.note import Note
def _aggregate_tag_counts(tag_lists) -> dict[str, int]: def _aggregate_tag_counts(tag_lists) -> dict[str, int]:
@@ -18,12 +18,12 @@ Sentinels (preserved from existing fable-mcp):
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import notes as notes_svc from fabledassistant.services import notes as notes_svc
from scribe.services import planning as planning_svc from fabledassistant.services import planning as planning_svc
from scribe.services import rulebooks as rulebooks_svc from fabledassistant.services import rulebooks as rulebooks_svc
from scribe.services import task_logs as task_logs_svc from fabledassistant.services import task_logs as task_logs_svc
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
async def list_tasks( async def list_tasks(
@@ -37,10 +37,7 @@ async def list_tasks(
Args: Args:
status: Filter by status one of: todo, in_progress, done, cancelled. Omit for all. status: Filter by status one of: todo, in_progress, done, cancelled. Omit for all.
project_id: Filter to a specific project. PASS THE ACTIVE PROJECT'S ID project_id: Filter to a specific project. Use 0 for no filter.
whenever a project is in scope so you list that project's tasks, not
every project's. 0 = no filter (all projects — use only for a
deliberate cross-project view).
kind: Filter by task kind 'work' or 'plan'. Omit (empty) for all kinds. kind: Filter by task kind 'work' or 'plan'. Omit (empty) for all kinds.
Results are ordered by last-updated descending. Results are ordered by last-updated descending.
@@ -6,8 +6,8 @@ the deleted_batch_id that a delete operation returns.
""" """
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id from fabledassistant.mcp._context import current_user_id
from scribe.services import trash as trash_svc from fabledassistant.services import trash as trash_svc
async def list_trash() -> dict: async def list_trash() -> dict:
+42
View File
@@ -0,0 +1,42 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from fabledassistant.config import Config
engine = create_async_engine(
Config.DATABASE_URL,
echo=False,
pool_pre_ping=True,
pool_recycle=1800,
)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin # noqa: E402, F401
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
from fabledassistant.models.setting import Setting # noqa: E402, F401
from fabledassistant.models.user import User # noqa: E402, F401
from fabledassistant.models.app_log import AppLog # noqa: E402, F401
from fabledassistant.models.password_reset import PasswordResetToken # noqa: E402, F401
from fabledassistant.models.invitation import InvitationToken # noqa: E402, F401
from fabledassistant.models.embedding import NoteEmbedding # noqa: E402, F401
from fabledassistant.models.project import Project # noqa: E402, F401
from fabledassistant.models.event import Event # noqa: E402, F401
from fabledassistant.models.milestone import Milestone # noqa: E402, F401
from fabledassistant.models.task_log import TaskLog # noqa: E402, F401
from fabledassistant.models.note_draft import NoteDraft # noqa: E402, F401
from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401
from fabledassistant.models.group import Group, GroupMembership # noqa: E402, F401
from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, F401
from fabledassistant.models.notification import Notification # noqa: E402, F401
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401
from fabledassistant.models.rulebook import ( # noqa: E402, F401
Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
)
@@ -3,8 +3,8 @@ from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import CreatedAtMixin from fabledassistant.models.base import CreatedAtMixin
class ApiKey(Base, CreatedAtMixin): class ApiKey(Base, CreatedAtMixin):
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Index, Integer, Text from sqlalchemy import DateTime, Float, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
class AppLog(Base): class AppLog(Base):
@@ -4,7 +4,7 @@ from sqlalchemy import DateTime, ForeignKey, Integer
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
class NoteEmbedding(Base): class NoteEmbedding(Base):
@@ -3,8 +3,8 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import SoftDeleteMixin from fabledassistant.models.base import SoftDeleteMixin
class Event(Base, SoftDeleteMixin): class Event(Base, SoftDeleteMixin):
@@ -3,8 +3,8 @@ from datetime import datetime, timezone
from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import CreatedAtMixin, TimestampMixin from fabledassistant.models.base import CreatedAtMixin, TimestampMixin
class Group(Base, TimestampMixin): class Group(Base, TimestampMixin):
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
class InvitationToken(Base): class InvitationToken(Base):
@@ -1,8 +1,8 @@
from sqlalchemy import ForeignKey, Integer, Text from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin
class Milestone(Base, TimestampMixin, SoftDeleteMixin): class Milestone(Base, TimestampMixin, SoftDeleteMixin):
@@ -5,8 +5,8 @@ from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin
class TaskStatus(str, enum.Enum): class TaskStatus(str, enum.Enum):
@@ -1,8 +1,8 @@
from sqlalchemy import ForeignKey, Integer, Text from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin from fabledassistant.models.base import TimestampMixin
class NoteDraft(Base, TimestampMixin): class NoteDraft(Base, TimestampMixin):
@@ -1,8 +1,8 @@
from sqlalchemy import ARRAY, ForeignKey, Integer, Text from sqlalchemy import ARRAY, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import CreatedAtMixin from fabledassistant.models.base import CreatedAtMixin
class NoteVersion(Base, CreatedAtMixin): class NoteVersion(Base, CreatedAtMixin):
@@ -4,8 +4,8 @@ from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import CreatedAtMixin from fabledassistant.models.base import CreatedAtMixin
class Notification(Base, CreatedAtMixin): class Notification(Base, CreatedAtMixin):
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
class PasswordResetToken(Base): class PasswordResetToken(Base):
@@ -1,8 +1,9 @@
import enum import enum
from sqlalchemy import ForeignKey, Integer, Text from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin
class ProjectStatus(str, enum.Enum): class ProjectStatus(str, enum.Enum):
@@ -21,6 +22,10 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
goal: Mapped[str] = mapped_column(Text, default="") goal: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="active") status: Mapped[str] = mapped_column(Text, default="active")
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
auto_summary: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
summary_updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -31,6 +36,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"goal": self.goal, "goal": self.goal,
"status": self.status, "status": self.status,
"color": self.color, "color": self.color,
"auto_summary": self.auto_summary,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
@@ -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 scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import SoftDeleteMixin from fabledassistant.models.base import SoftDeleteMixin
class Rulebook(Base, SoftDeleteMixin): class Rulebook(Base, SoftDeleteMixin):
@@ -1,7 +1,7 @@
from sqlalchemy import ForeignKey, Integer, Text from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
class Setting(Base): class Setting(Base):
@@ -1,8 +1,8 @@
from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin from fabledassistant.models.base import TimestampMixin
class ProjectShare(Base, TimestampMixin): class ProjectShare(Base, TimestampMixin):
@@ -1,8 +1,8 @@
from sqlalchemy import ForeignKey, Integer, Text from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin from fabledassistant.models.base import TimestampMixin
class TaskLog(Base, TimestampMixin): class TaskLog(Base, TimestampMixin):
@@ -1,8 +1,8 @@
from sqlalchemy import Index, Integer, Text from sqlalchemy import Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import CreatedAtMixin from fabledassistant.models.base import CreatedAtMixin
class User(Base, CreatedAtMixin): class User(Base, CreatedAtMixin):
@@ -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 scribe.models import Base from fabledassistant.models import Base
from scribe.models.base import TimestampMixin from fabledassistant.models.base import TimestampMixin
class UserProfile(Base, TimestampMixin): class UserProfile(Base, TimestampMixin):
@@ -3,8 +3,8 @@ import json
from quart import Blueprint, Response, g, jsonify, request from quart import Blueprint, Response, g, jsonify, request
from scribe.auth import admin_required, login_required, get_current_user_id from fabledassistant.auth import admin_required, login_required, get_current_user_id
from scribe.services.auth import ( from fabledassistant.services.auth import (
create_invitation, create_invitation,
delete_user, delete_user,
is_registration_open, is_registration_open,
@@ -13,15 +13,15 @@ from scribe.services.auth import (
revoke_invitation, revoke_invitation,
set_registration_open, set_registration_open,
) )
from scribe.services.backup import ( from fabledassistant.services.backup import (
export_full_backup, export_full_backup,
export_user_backup, export_user_backup,
restore_full_backup, restore_full_backup,
) )
from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email from fabledassistant.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from scribe.services.logging import get_logs, get_log_stats, log_audit from fabledassistant.services.logging import get_logs, get_log_stats, log_audit
from scribe.services.notifications import send_invitation_email from fabledassistant.services.notifications import send_invitation_email
from scribe.services.settings import set_setting, set_settings_batch from fabledassistant.services.settings import set_setting, set_settings_batch
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin") admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
@@ -1,7 +1,7 @@
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys") api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
@@ -6,10 +6,10 @@ import secrets
from quart import Blueprint, g, jsonify, redirect, request, session from quart import Blueprint, g, jsonify, redirect, request, session
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.config import Config from fabledassistant.config import Config
from scribe.rate_limit import is_rate_limited from fabledassistant.rate_limit import is_rate_limited
from scribe.services.auth import ( from fabledassistant.services.auth import (
authenticate, authenticate,
change_password, change_password,
create_password_reset_token, create_password_reset_token,
@@ -26,14 +26,14 @@ from scribe.services.auth import (
validate_invitation_token, validate_invitation_token,
verify_password, verify_password,
) )
from scribe.services.logging import log_audit from fabledassistant.services.logging import log_audit
from scribe.services.notifications import ( from fabledassistant.services.notifications import (
notify_security_event, notify_security_event,
send_password_reset_email, send_password_reset_email,
send_password_reset_success_email, send_password_reset_success_email,
) )
from scribe.services.email import get_base_url, is_smtp_configured from fabledassistant.services.email import get_base_url, is_smtp_configured
from scribe.services.oauth import ( from fabledassistant.services.oauth import (
build_auth_url, build_auth_url,
exchange_code, exchange_code,
get_userinfo, get_userinfo,
@@ -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 scribe.auth import login_required from fabledassistant.auth import login_required
from scribe.services.dashboard import build_dashboard from fabledassistant.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 scribe.auth import login_required from fabledassistant.auth import login_required
import scribe.services.events as events_svc import fabledassistant.services.events as events_svc
events_bp = Blueprint("events", __name__, url_prefix="/api/events") events_bp = Blueprint("events", __name__, url_prefix="/api/events")
@@ -126,7 +126,7 @@ async def update_event(event_id: int):
@events_bp.delete("/<int:event_id>") @events_bp.delete("/<int:event_id>")
@login_required @login_required
async def delete_event(event_id: int): async def delete_event(event_id: int):
from scribe.services.trash import delete as trash_delete from fabledassistant.services.trash import delete as trash_delete
batch = await trash_delete(_get_current_user_id(), "event", event_id) batch = await trash_delete(_get_current_user_id(), "event", event_id)
if batch is None: if batch is None:
return jsonify({"error": "Event not found"}), 404 return jsonify({"error": "Event not found"}), 404
@@ -137,6 +137,6 @@ async def delete_event(event_id: int):
@login_required @login_required
async def sync_caldav(): async def sync_caldav():
"""Trigger a CalDAV pull sync for the current user.""" """Trigger a CalDAV pull sync for the current user."""
from scribe.services.caldav_sync import sync_user_events from fabledassistant.services.caldav_sync import sync_user_events
result = await sync_user_events(user_id=_get_current_user_id()) result = await sync_user_events(user_id=_get_current_user_id())
return jsonify(result) return jsonify(result)
@@ -6,9 +6,9 @@ from datetime import datetime, timezone
from quart import Blueprint, Response, request from quart import Blueprint, Response, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.note import Note from fabledassistant.models.note import Note
from sqlalchemy import select from sqlalchemy import select
export_bp = Blueprint("export", __name__) export_bp = Blueprint("export", __name__)
@@ -83,7 +83,7 @@ async def export_data():
status=200, status=200,
headers={ headers={
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json; charset=utf-8",
"Content-Disposition": f'attachment; filename="scribe-{stamp}.json"', "Content-Disposition": f'attachment; filename="fabledassistant-{stamp}.json"',
}, },
) )
@@ -112,6 +112,6 @@ async def export_data():
status=200, status=200,
headers={ headers={
"Content-Type": "application/zip", "Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="scribe-{stamp}.zip"', "Content-Disposition": f'attachment; filename="fabledassistant-{stamp}.zip"',
}, },
) )
@@ -2,9 +2,9 @@ import asyncio
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from scribe.auth import get_current_user_id, login_required from fabledassistant.auth import get_current_user_id, login_required
from scribe.services import groups as group_svc from fabledassistant.services import groups as group_svc
from scribe.services.notifications import notify_group_added from fabledassistant.services.notifications import notify_group_added
groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups") groups_bp = Blueprint("groups", __name__, url_prefix="/api/groups")
@@ -1,7 +1,7 @@
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required from fabledassistant.auth import get_current_user_id, login_required
from scribe.services.notifications import ( from fabledassistant.services.notifications import (
list_in_app_notifications, list_in_app_notifications,
mark_all_notifications_read, mark_all_notifications_read,
mark_notification_read, mark_notification_read,
@@ -3,8 +3,8 @@ import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required from fabledassistant.auth import get_current_user_id, login_required
from scribe.routes.utils import parse_pagination from fabledassistant.routes.utils import parse_pagination
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -42,7 +42,7 @@ async def list_knowledge():
limit, offset = parse_pagination(default_limit=24, max_limit=100) limit, offset = parse_pagination(default_limit=24, max_limit=100)
page = max(1, int(request.args.get("page", 1))) page = max(1, int(request.args.get("page", 1)))
from scribe.services.knowledge import query_knowledge from fabledassistant.services.knowledge import query_knowledge
items, total = await query_knowledge( items, total = await query_knowledge(
user_id=uid, user_id=uid,
note_type=note_type, note_type=note_type,
@@ -88,7 +88,7 @@ async def list_knowledge_ids():
if note_type and note_type not in _VALID_TYPES: if note_type and note_type not in _VALID_TYPES:
return jsonify({"error": "Invalid type"}), 400 return jsonify({"error": "Invalid type"}), 400
from scribe.services.knowledge import query_knowledge_ids from fabledassistant.services.knowledge import query_knowledge_ids
ids, total = await query_knowledge_ids( ids, total = await query_knowledge_ids(
user_id=uid, note_type=note_type, tags=tags, user_id=uid, note_type=note_type, tags=tags,
sort=sort, q=q, limit=limit, offset=offset, sort=sort, q=q, limit=limit, offset=offset,
@@ -114,7 +114,7 @@ async def get_knowledge_batch():
if len(ids) > 100: if len(ids) > 100:
return jsonify({"error": "Too many IDs (max 100)"}), 400 return jsonify({"error": "Too many IDs (max 100)"}), 400
from scribe.services.knowledge import get_knowledge_by_ids from fabledassistant.services.knowledge import get_knowledge_by_ids
items = await get_knowledge_by_ids(uid, ids) items = await get_knowledge_by_ids(uid, ids)
return jsonify({"items": items}) return jsonify({"items": items})
@@ -126,7 +126,7 @@ async def list_knowledge_tags():
uid = get_current_user_id() uid = get_current_user_id()
note_type = request.args.get("type", "").strip().lower() or None note_type = request.args.get("type", "").strip().lower() or None
from scribe.services.knowledge import get_knowledge_tags from fabledassistant.services.knowledge import get_knowledge_tags
tags = await get_knowledge_tags(uid, note_type=note_type) tags = await get_knowledge_tags(uid, note_type=note_type)
return jsonify({"tags": tags}) return jsonify({"tags": tags})
@@ -139,6 +139,6 @@ async def get_knowledge_counts():
tags_raw = request.args.get("tags", "").strip() tags_raw = request.args.get("tags", "").strip()
tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else None tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else None
from scribe.services.knowledge import get_knowledge_counts as _counts from fabledassistant.services.knowledge import get_knowledge_counts as _counts
counts = await _counts(uid, tags=tags) counts = await _counts(uid, tags=tags)
return jsonify(counts) return jsonify(counts)
@@ -3,10 +3,10 @@ import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_pagination from fabledassistant.routes.utils import not_found, parse_pagination
from scribe.services.access import can_write_project from fabledassistant.services.access import can_write_project
from scribe.services.milestones import ( from fabledassistant.services.milestones import (
create_milestone, create_milestone,
delete_milestone, delete_milestone,
get_milestone_in_project, get_milestone_in_project,
@@ -14,8 +14,8 @@ from scribe.services.milestones import (
list_milestones, list_milestones,
update_milestone, update_milestone,
) )
from scribe.services.notes import list_notes from fabledassistant.services.notes import list_notes
from scribe.services.projects import get_project_for_user from fabledassistant.services.projects import get_project_for_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -113,7 +113,7 @@ async def delete_milestone_route(project_id: int, milestone_id: int):
milestone = await get_milestone_in_project(project_id, milestone_id) milestone = await get_milestone_in_project(project_id, milestone_id)
if milestone is None: if milestone is None:
return not_found("Milestone") return not_found("Milestone")
from scribe.services.trash import delete as trash_delete from fabledassistant.services.trash import delete as trash_delete
batch = await trash_delete(milestone.user_id, "milestone", milestone_id) batch = await trash_delete(milestone.user_id, "milestone", milestone_id)
if batch is None: if batch is None:
return not_found("Milestone") return not_found("Milestone")
@@ -2,14 +2,14 @@ import asyncio
import logging import logging
import re import re
from scribe.services.embeddings import upsert_note_embedding from fabledassistant.services.embeddings import upsert_note_embedding
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
from scribe.services.access import can_write_note from fabledassistant.services.access import can_write_note
from scribe.services.notes import ( from fabledassistant.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 scribe.services.notes import (
list_notes, list_notes,
update_note, update_note,
) )
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft from fabledassistant.services.note_drafts import upsert_draft, get_draft, delete_draft
from scribe.services.note_versions import list_versions, get_version from fabledassistant.services.note_versions import list_versions, get_version
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -89,7 +89,7 @@ async def create_note_route():
project_id = data.get("project_id") project_id = data.get("project_id")
if project_id is None and data.get("project"): if project_id is None and data.get("project"):
from scribe.services.projects import get_project_by_title as _gpbt from fabledassistant.services.projects import get_project_by_title as _gpbt
proj = await _gpbt(uid, data["project"]) proj = await _gpbt(uid, data["project"])
if proj: if proj:
project_id = proj.id project_id = proj.id
@@ -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 scribe.services.trash import delete as trash_delete from fabledassistant.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 scribe.services.version_pinning import pin_version from fabledassistant.services.version_pinning import pin_version
try: try:
version = await pin_version(uid, note_id, version_id, label=label) version = await pin_version(uid, note_id, version_id, label=label)
except ValueError as e: except ValueError as e:
@@ -462,7 +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 scribe.services.version_pinning import unpin_version from fabledassistant.services.version_pinning import unpin_version
version = await unpin_version(uid, note_id, version_id) version = await unpin_version(uid, note_id, version_id)
if version is None: if version is None:
return not_found("Version") return not_found("Version")
@@ -1,7 +1,7 @@
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required from fabledassistant.auth import get_current_user_id, login_required
from scribe.services.user_profile import ( from fabledassistant.services.user_profile import (
VALID_EXPERTISE, VALID_EXPERTISE,
VALID_STYLES, VALID_STYLES,
VALID_TONES, VALID_TONES,
@@ -4,11 +4,11 @@ import logging
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_pagination from fabledassistant.routes.utils import not_found, parse_pagination
from scribe.services.milestones import list_milestones from fabledassistant.services.milestones import list_milestones
from scribe.services.notes import list_notes from fabledassistant.services.notes import list_notes
from scribe.services.projects import ( from fabledassistant.services.projects import (
create_project, create_project,
delete_project, delete_project,
get_project, get_project,
@@ -101,7 +101,7 @@ async def update_project_route(project_id: int):
@login_required @login_required
async def delete_project_route(project_id: int): async def delete_project_route(project_id: int):
uid = get_current_user_id() uid = get_current_user_id()
from scribe.services.trash import delete as trash_delete from fabledassistant.services.trash import delete as trash_delete
batch = await trash_delete(uid, "project", project_id) batch = await trash_delete(uid, "project", project_id)
if batch is None: if batch is None:
return not_found("Project") return not_found("Project")
@@ -7,9 +7,9 @@ from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required from fabledassistant.auth import login_required
import scribe.services.rulebooks as rulebooks_svc import fabledassistant.services.rulebooks as rulebooks_svc
from scribe.services.trash import delete as trash_delete from fabledassistant.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 scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.services.embeddings import semantic_search_notes from fabledassistant.services.embeddings import semantic_search_notes
search_bp = Blueprint("search", __name__, url_prefix="/api/search") search_bp = Blueprint("search", __name__, url_prefix="/api/search")
@@ -10,10 +10,10 @@ from urllib.parse import urlparse
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.config import Config from fabledassistant.config import Config
from scribe.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection from fabledassistant.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
from scribe.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch from fabledassistant.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 scribe.auth import get_current_user_id, login_required from fabledassistant.auth import get_current_user_id, login_required
from scribe.services import sharing as share_svc from fabledassistant.services import sharing as share_svc
from scribe.services.access import can_admin_project, can_write_note from fabledassistant.services.access import can_admin_project, can_write_note
from scribe.services.notifications import notify_note_shared, notify_project_shared from fabledassistant.services.notifications import notify_note_shared, notify_project_shared
from scribe.services.sharing import list_shared_with_me from fabledassistant.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 scribe.auth import get_current_user_id, login_required from fabledassistant.auth import get_current_user_id, login_required
from scribe.services.task_logs import create_log, delete_log, list_logs, update_log from fabledassistant.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 scribe.services.task_logs import _UNSET from fabledassistant.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 scribe.auth import login_required, get_current_user_id from fabledassistant.auth import login_required, get_current_user_id
from scribe.models.note import TaskPriority, TaskStatus from fabledassistant.models.note import TaskPriority, TaskStatus
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
from scribe.services.access import can_write_note from fabledassistant.services.access import can_write_note
from scribe.services.embeddings import upsert_note_embedding from fabledassistant.services.embeddings import upsert_note_embedding
from scribe.services.notes import ( from fabledassistant.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 scribe.services.planning import start_planning as svc_start_planning from fabledassistant.services.planning import start_planning as svc_start_planning
from scribe.services.recurrence import calculate_next_due, validate_recurrence_rule from fabledassistant.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 scribe.services.projects import get_project_by_title as _gpbt from fabledassistant.services.projects import get_project_by_title as _gpbt
proj = await _gpbt(uid, data["project"]) proj = await _gpbt(uid, data["project"])
if proj: if proj:
project_id = proj.id project_id = proj.id
@@ -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 scribe.services.trash import delete as trash_delete from fabledassistant.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 scribe.auth import login_required from fabledassistant.auth import login_required
import scribe.services.trash as trash_svc import fabledassistant.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 scribe.auth import get_current_user_id, login_required from fabledassistant.auth import get_current_user_id, login_required
from scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.user import User from fabledassistant.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 scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.group import GroupMembership from fabledassistant.models.group import GroupMembership
from scribe.models.note import Note from fabledassistant.models.note import Note
from scribe.models.project import Project from fabledassistant.models.project import Project
from scribe.models.share import NoteShare, ProjectShare from fabledassistant.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 scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.api_key import ApiKey from fabledassistant.models.api_key import ApiKey
def generate_key() -> str: def generate_key() -> str:
@@ -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 scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.note import Note from fabledassistant.models.note import Note
from scribe.models.invitation import InvitationToken from fabledassistant.models.invitation import InvitationToken
from scribe.models.password_reset import PasswordResetToken from fabledassistant.models.password_reset import PasswordResetToken
from scribe.models.setting import Setting from fabledassistant.models.setting import Setting
from scribe.models.user import User from fabledassistant.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 scribe.services.settings import set_setting from fabledassistant.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 scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.milestone import Milestone from fabledassistant.models.milestone import Milestone
from scribe.models.note import Note from fabledassistant.models.note import Note
from scribe.models.note_draft import NoteDraft from fabledassistant.models.note_draft import NoteDraft
from scribe.models.note_version import NoteVersion from fabledassistant.models.note_version import NoteVersion
from scribe.models.project import Project from fabledassistant.models.project import Project
from scribe.models.setting import Setting from fabledassistant.models.setting import Setting
from scribe.models.task_log import TaskLog from fabledassistant.models.task_log import TaskLog
from scribe.models.user import User from fabledassistant.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 scribe.services.settings import get_all_settings from fabledassistant.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", "-//Scribe//EN") cal.add("prodid", "-//FabledAssistant//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", "-//Scribe//EN") cal_data.add("prodid", "-//FabledAssistant//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", "-//Scribe//EN") cal_data.add("prodid", "-//FabledAssistant//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 scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.event import Event from fabledassistant.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 scribe.services.caldav import get_caldav_config, is_caldav_configured # noqa: PLC0415 from fabledassistant.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 scribe.models.user import User # noqa: PLC0415 from fabledassistant.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 scribe.models import async_session from fabledassistant.models import async_session
from scribe.models.note import Note from fabledassistant.models.note import Note
from scribe.models.project import Project from fabledassistant.models.project import Project
from scribe.models.milestone import Milestone from fabledassistant.models.milestone import Milestone
from scribe.services import milestones as milestones_svc from fabledassistant.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 scribe.services import events as events_svc from fabledassistant.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 = []
@@ -5,10 +5,11 @@ clear cause in the logs. This module adds three things designed to make
the crash class identifiable from logs alone: the crash class identifiable from logs alone:
1. **Heartbeat** once per minute, log a snapshot of process resources 1. **Heartbeat** once per minute, log a snapshot of process resources
(RSS memory, asyncio task count, DB pool checked-in/out). A sudden (RSS memory, asyncio task count, DB pool checked-in/out, curator
silence in heartbeats lets you bound the crash time to within a busy state). A sudden silence in heartbeats lets you bound the
minute, and the last snapshot before silence usually rules in or out crash time to within a minute, and the last snapshot before silence
memory growth / pool exhaustion. usually rules in or out memory growth / pool exhaustion / hung
curator pass.
2. **Signal handler** catches SIGTERM and SIGINT, logs them with the 2. **Signal handler** catches SIGTERM and SIGINT, logs them with the
sender's intent ("docker stop", "swarm restart", "manual ctrl-C") sender's intent ("docker stop", "swarm restart", "manual ctrl-C")
@@ -85,7 +86,7 @@ def _process_rss_mb() -> float | None:
def _db_pool_stats() -> dict[str, Any]: def _db_pool_stats() -> dict[str, Any]:
"""Pool checked-in / checked-out / overflow. Direct from SQLAlchemy.""" """Pool checked-in / checked-out / overflow. Direct from SQLAlchemy."""
try: try:
from scribe.models import engine from fabledassistant.models import engine
pool = engine.pool pool = engine.pool
# Async engines wrap a sync pool; .checkedin() / .checkedout() exist # Async engines wrap a sync pool; .checkedin() / .checkedout() exist
# on the underlying QueuePool. Attribute access is documented but # on the underlying QueuePool. Attribute access is documented but
@@ -107,6 +108,11 @@ def _asyncio_task_count() -> int | None:
return None return None
def _curator_busy() -> bool | None:
# Curator was removed in Phase 8 of the MCP-first pivot. Always False.
return False
def _uptime_secs() -> float | None: def _uptime_secs() -> float | None:
if _started_at is None: if _started_at is None:
return None return None
@@ -128,6 +134,7 @@ def _snapshot_dict(reason: str = "heartbeat") -> dict[str, Any]:
"rss_mb": _process_rss_mb(), "rss_mb": _process_rss_mb(),
"asyncio_tasks": _asyncio_task_count(), "asyncio_tasks": _asyncio_task_count(),
"db_pool": _db_pool_stats(), "db_pool": _db_pool_stats(),
"curator_busy": _curator_busy(),
"pid": os.getpid(), "pid": os.getpid(),
} }
@@ -165,7 +172,7 @@ def _setup_file_logger() -> logging.Logger:
return _file_logger return _file_logger
try: try:
_DIAG_DIR.mkdir(parents=True, exist_ok=True) _DIAG_DIR.mkdir(parents=True, exist_ok=True)
flog = logging.getLogger("scribe.diagnostics.persistent") flog = logging.getLogger("fabledassistant.diagnostics.persistent")
flog.setLevel(logging.INFO) flog.setLevel(logging.INFO)
flog.propagate = False # don't double-log into the root stdout stream flog.propagate = False # don't double-log into the root stdout stream
# Don't re-add handlers across module reloads / re-runs. # Don't re-add handlers across module reloads / re-runs.
@@ -277,9 +284,9 @@ async def _heartbeat_loop() -> None:
_persistent_log( _persistent_log(
logging.INFO, logging.INFO,
"diag heartbeat: uptime=%ss rss=%sMB asyncio_tasks=%s " "diag heartbeat: uptime=%ss rss=%sMB asyncio_tasks=%s "
"db_pool=%s", "db_pool=%s curator_busy=%s",
snap["uptime_secs"], snap["rss_mb"], snap["asyncio_tasks"], snap["uptime_secs"], snap["rss_mb"], snap["asyncio_tasks"],
snap["db_pool"], snap["db_pool"], snap["curator_busy"],
) )
_write_state_atomic(_CURRENT_STATE_PATH, snap) _write_state_atomic(_CURRENT_STATE_PATH, snap)
except Exception: except Exception:

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