Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51feaddcd3 | |||
| e3c6124912 | |||
| 964c8005d5 | |||
| 70ab3f38c6 | |||
| b255a0f90e | |||
| 9ddc418f5f | |||
| 1d4c206563 | |||
| 301352f628 | |||
| d4666bea7f | |||
| 837489e4f2 | |||
| 9a0d5f3109 | |||
| 5a930319ba | |||
| 266af7870d | |||
| f446573c3d | |||
| 82d6812c7f | |||
| 8c9ca45479 | |||
| e023c21aa1 | |||
| e3d7007417 | |||
| 65c85bab15 | |||
| 7ef7d10b24 | |||
| 6a3619555d | |||
| 8b3bd4804e | |||
| 74b337b587 | |||
| fb1ae915e4 | |||
| c2b2694ea3 | |||
| 7b5a75989a | |||
| 1babe59843 | |||
| 2c929a0435 | |||
| cf4962d7e8 | |||
| 64bc50c788 | |||
| c39d7356ed | |||
| 8d739c5da1 | |||
| 7ce5bb8450 | |||
| 2fd9a2300a | |||
| c016bd664e | |||
| 4a220db513 | |||
| aef5009fc2 | |||
| c363a5a6df | |||
| 5fe0fd126d | |||
| 8b49ea896a | |||
| e70fe545cc | |||
| 0e980ee4b0 | |||
| 7861607fb8 | |||
| b5870d4694 | |||
| c5469214e3 | |||
| 43a860c3ac | |||
| 658348f208 | |||
| c810d63bee | |||
| fd20b67b22 | |||
| 7031e36670 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
POSTGRES_USER=fabled
|
POSTGRES_USER=fabled
|
||||||
POSTGRES_PASSWORD=fabled
|
POSTGRES_PASSWORD=fabled
|
||||||
POSTGRES_DB=fabledassistant
|
POSTGRES_DB=scribe
|
||||||
SECRET_KEY=dev-secret-change-me
|
SECRET_KEY=dev-secret-change-me
|
||||||
|
|||||||
+28
-20
@@ -1,12 +1,19 @@
|
|||||||
# 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>
|
||||||
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
|
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
||||||
|
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
||||||
#
|
#
|
||||||
# main pushes are NOT gated here: a merge to main only happens after
|
# Both dev and main are gated AND built. dev pushes move :dev; main pushes move
|
||||||
# dev has already passed CI, and the release tag is the sole trigger
|
# :latest — main IS the production line, so :latest tracks main's tip and there
|
||||||
# for a production image. Re-running CI on the merge commit just burns
|
# is no separate :main tag. Every push also gets an immutable :<sha> (the
|
||||||
# runner time without changing the outcome.
|
# rollback point). A v* release tag additionally publishes the dated :<version>;
|
||||||
|
# since main already moved :latest, the release tag's distinct job is that
|
||||||
|
# :<version> marker (it refreshes :latest too, harmlessly).
|
||||||
|
#
|
||||||
|
# Successive pushes to the SAME ref supersede each other (see concurrency
|
||||||
|
# below), so rapid pushes don't stack identical work; dev and main runs are
|
||||||
|
# independent refs and never cancel one another.
|
||||||
#
|
#
|
||||||
# To cut a release:
|
# To cut a release:
|
||||||
# Create a release via the Forgejo UI on main with a v* tag name.
|
# Create a release via the Forgejo UI on main with a v* tag name.
|
||||||
@@ -16,11 +23,8 @@
|
|||||||
# gating on branch push is already enough.
|
# gating on branch push is already enough.
|
||||||
#
|
#
|
||||||
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
|
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
|
||||||
# honor `on.push.branches` as a filter — merge commits landing on main
|
# honor `on.push.branches` as a filter, so every job repeats the ref check
|
||||||
# still trigger the workflow, producing redundant runs on the same SHA
|
# explicitly — permitting dev, main, and v* tags, rejecting anything else.
|
||||||
# that was already gated on dev. Every job therefore repeats the ref
|
|
||||||
# check so main pushes trigger the workflow but every job skips
|
|
||||||
# immediately (no runner time, no duplicate work).
|
|
||||||
#
|
#
|
||||||
# Required secrets (repo → Settings → Secrets → Actions):
|
# Required secrets (repo → Settings → Secrets → Actions):
|
||||||
# REGISTRY_USER — your Forgejo username
|
# REGISTRY_USER — your Forgejo username
|
||||||
@@ -29,7 +33,7 @@ name: CI & Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev]
|
branches: [dev, main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
paths:
|
paths:
|
||||||
- "src/**"
|
- "src/**"
|
||||||
@@ -67,8 +71,8 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
typecheck:
|
typecheck:
|
||||||
name: TypeScript typecheck
|
name: TypeScript typecheck
|
||||||
# Skip on main merge-commit pushes — see workflow header comment.
|
# Gate dev, main, and v* tags; reject any other ref (see header note).
|
||||||
if: github.ref == 'refs/heads/dev' || 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
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -92,7 +96,7 @@ jobs:
|
|||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Python lint
|
name: Python lint
|
||||||
if: github.ref == 'refs/heads/dev' || 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
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -106,7 +110,7 @@ jobs:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
name: Python tests
|
name: Python tests
|
||||||
if: github.ref == 'refs/heads/dev' || 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
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -138,11 +142,9 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build & push image
|
name: Build & push image
|
||||||
needs: [typecheck, lint, test]
|
needs: [typecheck, lint, test]
|
||||||
# Build on dev branch pushes and version tag pushes only.
|
# Build on dev, main, and v* tag pushes. dev → :dev, main → :latest,
|
||||||
# Mirrors the ref guard on the gate jobs above — main merge-commit
|
# tag → :latest + :<version>; every build also gets an immutable :<sha>.
|
||||||
# pushes skip here too, so no production image is ever built from a
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
# raw main push (only from the v* tag the release creates).
|
|
||||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -168,6 +170,12 @@ jobs:
|
|||||||
refs/heads/dev)
|
refs/heads/dev)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||||
;;
|
;;
|
||||||
|
refs/heads/main)
|
||||||
|
# main IS the production line: publish :latest (plus the :<sha>
|
||||||
|
# set above). No separate :main tag.
|
||||||
|
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||||
|
BUILD_VERSION="main"
|
||||||
|
;;
|
||||||
refs/tags/*)
|
refs/tags/*)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||||
BUILD_VERSION="${{ github.ref_name }}"
|
BUILD_VERSION="${{ github.ref_name }}"
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
632037
|
1425947
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ COPY src/ src/
|
|||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
pip install .
|
pip install .
|
||||||
|
|
||||||
COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
|
COPY --from=build-frontend /build/dist/ src/scribe/static/
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
COPY alembic/ alembic/
|
COPY alembic/ alembic/
|
||||||
|
|
||||||
@@ -30,4 +30,4 @@ ARG BUILD_VERSION=dev
|
|||||||
ENV APP_VERSION=$BUILD_VERSION
|
ENV APP_VERSION=$BUILD_VERSION
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ A self-hosted second brain and project management application with integrated LL
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, an MCP server for external AI clients, and an Android companion app.
|
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, and an MCP server for external AI clients.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -36,7 +36,6 @@ Open `http://localhost:5000`. The first user to register becomes admin. Go to **
|
|||||||
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide |
|
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide |
|
||||||
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
|
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
|
||||||
| [API Reference](docs/api-reference.md) | All REST API endpoints |
|
| [API Reference](docs/api-reference.md) | All REST API endpoints |
|
||||||
| [Android App](docs/android-app.md) | Flutter companion app architecture and feature status |
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@ from logging.config import fileConfig
|
|||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from fabledassistant.config import Config
|
from scribe.config import Config
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""rulebook always_on flag
|
||||||
|
|
||||||
|
Revision ID: 0058
|
||||||
|
Revises: 0057
|
||||||
|
Create Date: 2026-06-01
|
||||||
|
|
||||||
|
Adds a boolean `always_on` to the `rulebooks` table. Rules from rulebooks
|
||||||
|
flagged always_on are loaded at session start by the new
|
||||||
|
`list_always_on_rules` MCP tool — they apply regardless of which project
|
||||||
|
(if any) is in scope. Seeds the FabledSword family rulebook to always_on
|
||||||
|
because that's the cross-project standards rulebook by design.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0058"
|
||||||
|
down_revision = "0057"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"rulebooks",
|
||||||
|
sa.Column(
|
||||||
|
"always_on",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE rulebooks SET always_on = TRUE WHERE title = 'FabledSword family'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("rulebooks", "always_on")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""project-scoped rules
|
||||||
|
|
||||||
|
Revision ID: 0059
|
||||||
|
Revises: 0058
|
||||||
|
Create Date: 2026-06-01
|
||||||
|
|
||||||
|
Rules can now belong to either a rulebook topic (cross-project standard) or
|
||||||
|
a single project (project-scoped). Adds `rules.project_id`, makes `topic_id`
|
||||||
|
nullable, and adds a CHECK constraint enforcing exactly-one. The previous
|
||||||
|
unique constraint on (topic_id, title) still applies because PostgreSQL
|
||||||
|
treats NULL as distinct — two project-scoped rules with the same title and
|
||||||
|
NULL topic_id remain unique.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0059"
|
||||||
|
down_revision = "0058"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"rules",
|
||||||
|
sa.Column(
|
||||||
|
"project_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.alter_column("rules", "topic_id", nullable=True)
|
||||||
|
op.create_index("ix_rules_project_id", "rules", ["project_id"])
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_rule_topic_xor_project",
|
||||||
|
"rules",
|
||||||
|
"(topic_id IS NULL) <> (project_id IS NULL)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("ck_rule_topic_xor_project", "rules", type_="check")
|
||||||
|
op.drop_index("ix_rules_project_id", table_name="rules")
|
||||||
|
# Any rule with NULL topic_id will block re-tightening. Operator must
|
||||||
|
# migrate or delete project-scoped rules before downgrading.
|
||||||
|
op.alter_column("rules", "topic_id", nullable=False)
|
||||||
|
op.drop_column("rules", "project_id")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""project rule + topic suppressions
|
||||||
|
|
||||||
|
Revision ID: 0060
|
||||||
|
Revises: 0059
|
||||||
|
Create Date: 2026-06-01
|
||||||
|
|
||||||
|
Lets a project mute specific rules or whole topics from rulebooks it
|
||||||
|
subscribes to, without unsubscribing the rulebook. Two pure many-to-many
|
||||||
|
association tables; FKs CASCADE so removing a project / rule / topic
|
||||||
|
cleans the suppression rows automatically.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0060"
|
||||||
|
down_revision = "0059"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"project_rule_suppressions",
|
||||||
|
sa.Column(
|
||||||
|
"project_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"rule_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("rules.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"project_topic_suppressions",
|
||||||
|
sa.Column(
|
||||||
|
"project_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"topic_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("project_topic_suppressions")
|
||||||
|
op.drop_table("project_rule_suppressions")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""partial-unique topic/rule titles (ignore soft-deleted rows)
|
||||||
|
|
||||||
|
Revision ID: 0061
|
||||||
|
Revises: 0060
|
||||||
|
Create Date: 2026-06-02
|
||||||
|
|
||||||
|
Topics and rules are soft-deleted (SoftDeleteMixin), but uq_topic_per_rulebook
|
||||||
|
and uq_rule_per_topic were plain UNIQUE constraints. Trashing a topic/rule
|
||||||
|
named "X" then creating a new "X" — or restoring into a reused title slot —
|
||||||
|
collided with the dead row and raised an unhandled 500. Replace the full
|
||||||
|
UNIQUE constraints with partial unique indexes that only consider live
|
||||||
|
(deleted_at IS NULL) rows.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0061"
|
||||||
|
down_revision = "0060"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_constraint("uq_topic_per_rulebook", "rulebook_topics", type_="unique")
|
||||||
|
op.create_index(
|
||||||
|
"uq_topic_per_rulebook",
|
||||||
|
"rulebook_topics",
|
||||||
|
["rulebook_id", "title"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||||
|
)
|
||||||
|
op.drop_constraint("uq_rule_per_topic", "rules", type_="unique")
|
||||||
|
op.create_index(
|
||||||
|
"uq_rule_per_topic",
|
||||||
|
"rules",
|
||||||
|
["topic_id", "title"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_rule_per_topic", table_name="rules")
|
||||||
|
op.create_unique_constraint("uq_rule_per_topic", "rules", ["topic_id", "title"])
|
||||||
|
op.drop_index("uq_topic_per_rulebook", table_name="rulebook_topics")
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_topic_per_rulebook", "rulebook_topics", ["rulebook_id", "title"]
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""drop dead UserProfile curator columns
|
||||||
|
|
||||||
|
Revision ID: 0062
|
||||||
|
Revises: 0061
|
||||||
|
Create Date: 2026-06-02
|
||||||
|
|
||||||
|
learned_summary, observations_raw, and observations_updated_at were written by
|
||||||
|
the curator/LLM-profile machinery removed in the Phase-8 MCP pivot. Nothing has
|
||||||
|
populated them since; the profile API returned permanently-empty fields. Drop
|
||||||
|
the columns.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0062"
|
||||||
|
down_revision = "0061"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("user_profiles", "learned_summary")
|
||||||
|
op.drop_column("user_profiles", "observations_raw")
|
||||||
|
op.drop_column("user_profiles", "observations_updated_at")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"user_profiles",
|
||||||
|
sa.Column("observations_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"user_profiles",
|
||||||
|
sa.Column("observations_raw", postgresql.JSONB(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"user_profiles",
|
||||||
|
sa.Column("learned_summary", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""drop dead Project.auto_summary columns
|
||||||
|
|
||||||
|
Revision ID: 0063
|
||||||
|
Revises: 0062
|
||||||
|
Create Date: 2026-06-03
|
||||||
|
|
||||||
|
auto_summary + summary_updated_at were written by generate_project_summary
|
||||||
|
(Ollama), removed in the MCP pivot. Nothing has populated them since; the
|
||||||
|
field was stale-or-NULL. Drop the columns.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0063"
|
||||||
|
down_revision = "0062"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("projects", "auto_summary")
|
||||||
|
op.drop_column("projects", "summary_updated_at")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"projects",
|
||||||
|
sa.Column("summary_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"projects",
|
||||||
|
sa.Column("auto_summary", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: git.fabledsword.com/bvandeusen/fabledassistant:latest
|
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant"
|
DATABASE_URL: "postgresql+asyncpg://scribe:${DB_PASSWORD}@db:5432/scribe"
|
||||||
SECRET_KEY: "${SECRET_KEY}"
|
SECRET_KEY: "${SECRET_KEY}"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||||
TRUST_PROXY_HEADERS: "true"
|
TRUST_PROXY_HEADERS: "true"
|
||||||
SECURE_COOKIES: "true"
|
SECURE_COOKIES: "true"
|
||||||
networks:
|
networks:
|
||||||
- fabledassistant_backend
|
- scribe_backend
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -26,16 +26,16 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: fabled
|
POSTGRES_USER: scribe
|
||||||
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
||||||
POSTGRES_DB: fabledassistant
|
POSTGRES_DB: scribe
|
||||||
networks:
|
networks:
|
||||||
- fabledassistant_backend
|
- scribe_backend
|
||||||
# Lenient by design: a transient host exec/healthcheck stall (incident:
|
# Lenient by design: a transient host exec/healthcheck stall (incident:
|
||||||
# runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never
|
# runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never
|
||||||
# escalate to killing the DB. Health here only gates app startup order.
|
# escalate to killing the DB. Health here only gates app startup order.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U fabled"]
|
test: ["CMD-SHELL", "pg_isready -U scribe"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -51,5 +51,5 @@ volumes:
|
|||||||
pgdata:
|
pgdata:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
fabledassistant_backend:
|
scribe_backend:
|
||||||
driver: overlay
|
driver: overlay
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant"
|
DATABASE_URL: "postgresql+asyncpg://scribe:scribe@db:5432/scribe"
|
||||||
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
|
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -40,13 +40,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: fabled
|
POSTGRES_USER: scribe
|
||||||
POSTGRES_PASSWORD: fabled
|
POSTGRES_PASSWORD: scribe
|
||||||
POSTGRES_DB: fabledassistant
|
POSTGRES_DB: scribe
|
||||||
# Lenient by design: a transient host exec/healthcheck stall must never
|
# Lenient by design: a transient host exec/healthcheck stall must never
|
||||||
# escalate to killing the DB. Health here only gates app startup order.
|
# escalate to killing the DB. Health here only gates app startup order.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U fabled"]
|
test: ["CMD-SHELL", "pg_isready -U scribe"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
+5
-5
@@ -11,7 +11,7 @@ services:
|
|||||||
# To use a bind mount instead (gives direct host access to all app data):
|
# To use a bind mount instead (gives direct host access to all app data):
|
||||||
# - ./data:/data
|
# - ./data:/data
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
|
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-scribe}:${POSTGRES_PASSWORD:-scribe}@db:5432/${POSTGRES_DB:-scribe}"
|
||||||
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
||||||
# Uncomment if you have a SearXNG instance you want to surface in the
|
# Uncomment if you have a SearXNG instance you want to surface in the
|
||||||
# Integrations tab as a configured web-search backend:
|
# Integrations tab as a configured web-search backend:
|
||||||
@@ -30,13 +30,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-fabled}
|
POSTGRES_USER: ${POSTGRES_USER:-scribe}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabled}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scribe}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-fabledassistant}
|
POSTGRES_DB: ${POSTGRES_DB:-scribe}
|
||||||
# Lenient by design: a transient host exec/healthcheck stall must never
|
# Lenient by design: a transient host exec/healthcheck stall must never
|
||||||
# escalate to killing the DB. Health here only gates app startup order.
|
# escalate to killing the DB. Health here only gates app startup order.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fabled}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scribe}"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ A Python MCP (Model Context Protocol) server that lets Claude directly interface
|
|||||||
|
|
||||||
The work is split into two sub-projects:
|
The work is split into two sub-projects:
|
||||||
|
|
||||||
1. **Fable API Key Feature** — additions to the main `fabledassistant` project to support bearer token authentication
|
1. **Fable API Key Feature** — additions to the main `scribe` project to support bearer token authentication
|
||||||
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
|
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
|
||||||
|
|
||||||
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
|
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
|
||||||
@@ -134,7 +134,7 @@ The MCP tool reads tokens until it receives `type: "done"`, then returns `respon
|
|||||||
|
|
||||||
### Location
|
### Location
|
||||||
|
|
||||||
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `fabledassistant`. It will be extracted to its own Forgejo repo once stable.
|
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `scribe`. It will be extracted to its own Forgejo repo once stable.
|
||||||
|
|
||||||
### Package Structure
|
### Package Structure
|
||||||
|
|
||||||
@@ -256,7 +256,7 @@ Claude Code spawns the process over stdio automatically. No Docker, no daemon.
|
|||||||
|
|
||||||
## Build & Repo Plan
|
## Build & Repo Plan
|
||||||
|
|
||||||
1. Implement and test within `fabledassistant/fable-mcp/`
|
1. Implement and test within `scribe/fable-mcp/`
|
||||||
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
|
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
|
||||||
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
|
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
**Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done.
|
**Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done.
|
||||||
|
|
||||||
**Deployment decision:** `fable-mcp/` stays permanently inside the `fabledassistant` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance.
|
**Deployment decision:** `fable-mcp/` stays permanently inside the `scribe` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance.
|
||||||
|
|
||||||
**Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both.
|
**Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both.
|
||||||
|
|
||||||
@@ -23,16 +23,16 @@
|
|||||||
| Action | File | Purpose |
|
| Action | File | Purpose |
|
||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
| Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table |
|
| Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table |
|
||||||
| Create | `src/fabledassistant/models/api_key.py` | `ApiKey` SQLAlchemy model |
|
| Create | `src/scribe/models/api_key.py` | `ApiKey` SQLAlchemy model |
|
||||||
| Modify | `src/fabledassistant/models/__init__.py` | Export `ApiKey` |
|
| Modify | `src/scribe/models/__init__.py` | Export `ApiKey` |
|
||||||
| Create | `src/fabledassistant/services/api_keys.py` | create/list/revoke/lookup service functions |
|
| Create | `src/scribe/services/api_keys.py` | create/list/revoke/lookup service functions |
|
||||||
| Modify | `src/fabledassistant/auth.py` | Add bearer token check before session fallback |
|
| Modify | `src/scribe/auth.py` | Add bearer token check before session fallback |
|
||||||
| Create | `src/fabledassistant/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint |
|
| Create | `src/scribe/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint |
|
||||||
| Modify | `src/fabledassistant/app.py` | Register `api_keys_bp` and `search_bp` |
|
| Modify | `src/scribe/app.py` | Register `api_keys_bp` and `search_bp` |
|
||||||
| Modify | `src/fabledassistant/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` |
|
| Modify | `src/scribe/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` |
|
||||||
| Modify | `src/fabledassistant/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` |
|
| Modify | `src/scribe/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` |
|
||||||
| Modify | `src/fabledassistant/routes/chat.py:73-79` | Pass `conversation_type` from POST body |
|
| Modify | `src/scribe/routes/chat.py:73-79` | Pass `conversation_type` from POST body |
|
||||||
| Create | `src/fabledassistant/routes/search.py` | `GET /api/search` semantic search endpoint |
|
| Create | `src/scribe/routes/search.py` | `GET /api/search` semantic search endpoint |
|
||||||
| Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab |
|
| Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab |
|
||||||
| Create | `tests/test_api_keys.py` | Unit tests for service + auth |
|
| Create | `tests/test_api_keys.py` | Unit tests for service + auth |
|
||||||
| Create | `tests/test_search_route.py` | Unit test for search endpoint |
|
| Create | `tests/test_search_route.py` | Unit test for search endpoint |
|
||||||
@@ -42,13 +42,13 @@
|
|||||||
### Task 1: ApiKey model + migration
|
### Task 1: ApiKey model + migration
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `src/fabledassistant/models/api_key.py`
|
- Create: `src/scribe/models/api_key.py`
|
||||||
- Create: `alembic/versions/0027_add_api_keys.py`
|
- Create: `alembic/versions/0027_add_api_keys.py`
|
||||||
- Modify: `src/fabledassistant/models/__init__.py`
|
- Modify: `src/scribe/models/__init__.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Write the model**
|
- [ ] **Step 1: Write the model**
|
||||||
|
|
||||||
Create `src/fabledassistant/models/api_key.py`:
|
Create `src/scribe/models/api_key.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -56,8 +56,8 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import CreatedAtMixin
|
from scribe.models.base import CreatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
class ApiKey(Base, CreatedAtMixin):
|
class ApiKey(Base, CreatedAtMixin):
|
||||||
@@ -97,10 +97,10 @@ class ApiKey(Base, CreatedAtMixin):
|
|||||||
|
|
||||||
- [ ] **Step 2: Export from models __init__**
|
- [ ] **Step 2: Export from models __init__**
|
||||||
|
|
||||||
In `src/fabledassistant/models/__init__.py`, add after the last import line:
|
In `src/scribe/models/__init__.py`, add after the last import line:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
|
from scribe.models.api_key import ApiKey # noqa: E402, F401
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Write the migration**
|
- [ ] **Step 3: Write the migration**
|
||||||
@@ -155,8 +155,8 @@ Expected: `Running upgrade 0026 -> 0027, add api_keys table`
|
|||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/fabledassistant/models/api_key.py \
|
git add src/scribe/models/api_key.py \
|
||||||
src/fabledassistant/models/__init__.py \
|
src/scribe/models/__init__.py \
|
||||||
alembic/versions/0027_add_api_keys.py
|
alembic/versions/0027_add_api_keys.py
|
||||||
git commit -m "feat: add ApiKey model and migration 0027"
|
git commit -m "feat: add ApiKey model and migration 0027"
|
||||||
```
|
```
|
||||||
@@ -166,7 +166,7 @@ git commit -m "feat: add ApiKey model and migration 0027"
|
|||||||
### Task 2: ApiKey service
|
### Task 2: ApiKey service
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `src/fabledassistant/services/api_keys.py`
|
- Create: `src/scribe/services/api_keys.py`
|
||||||
- Create: `tests/test_api_keys.py` (service tests)
|
- Create: `tests/test_api_keys.py` (service tests)
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
- [ ] **Step 1: Write the failing tests**
|
||||||
@@ -181,7 +181,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from fabledassistant.services.api_keys import (
|
from scribe.services.api_keys import (
|
||||||
_hash_key,
|
_hash_key,
|
||||||
generate_key,
|
generate_key,
|
||||||
create_api_key,
|
create_api_key,
|
||||||
@@ -212,7 +212,7 @@ def test_hash_key_is_sha256():
|
|||||||
def test_generate_key_prefix():
|
def test_generate_key_prefix():
|
||||||
key = "fmcp_abcdefghijklmnop"
|
key = "fmcp_abcdefghijklmnop"
|
||||||
# prefix is first 12 chars of the full key
|
# prefix is first 12 chars of the full key
|
||||||
from fabledassistant.services.api_keys import _key_prefix
|
from scribe.services.api_keys import _key_prefix
|
||||||
assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars
|
assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars
|
||||||
|
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ async def test_create_api_key_returns_full_key():
|
|||||||
mock_key_obj.id = 1
|
mock_key_obj.id = 1
|
||||||
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
|
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
|
||||||
|
|
||||||
with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx:
|
with patch("scribe.services.api_keys.async_session") as mock_session_ctx:
|
||||||
mock_session = AsyncMock()
|
mock_session = AsyncMock()
|
||||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||||
@@ -238,7 +238,7 @@ async def test_create_api_key_returns_full_key():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_lookup_key_returns_none_for_unknown():
|
async def test_lookup_key_returns_none_for_unknown():
|
||||||
with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx:
|
with patch("scribe.services.api_keys.async_session") as mock_session_ctx:
|
||||||
mock_session = AsyncMock()
|
mock_session = AsyncMock()
|
||||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||||
@@ -262,7 +262,7 @@ Expected: `ImportError` or `ModuleNotFoundError` (service doesn't exist yet)
|
|||||||
|
|
||||||
- [ ] **Step 3: Write the service**
|
- [ ] **Step 3: Write the service**
|
||||||
|
|
||||||
Create `src/fabledassistant/services/api_keys.py`:
|
Create `src/scribe/services/api_keys.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -271,8 +271,8 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from fabledassistant.models import async_session
|
from scribe.models import async_session
|
||||||
from fabledassistant.models.api_key import ApiKey
|
from scribe.models.api_key import ApiKey
|
||||||
|
|
||||||
|
|
||||||
def generate_key() -> str:
|
def generate_key() -> str:
|
||||||
@@ -365,7 +365,7 @@ Expected: all tests pass
|
|||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/fabledassistant/services/api_keys.py tests/test_api_keys.py
|
git add src/scribe/services/api_keys.py tests/test_api_keys.py
|
||||||
git commit -m "feat: add ApiKey service with create/list/revoke/lookup"
|
git commit -m "feat: add ApiKey service with create/list/revoke/lookup"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ git commit -m "feat: add ApiKey service with create/list/revoke/lookup"
|
|||||||
### Task 3: Auth middleware — bearer token support
|
### Task 3: Auth middleware — bearer token support
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `src/fabledassistant/auth.py`
|
- Modify: `src/scribe/auth.py`
|
||||||
- Modify: `tests/test_api_keys.py` (add auth middleware tests)
|
- Modify: `tests/test_api_keys.py` (add auth middleware tests)
|
||||||
|
|
||||||
- [ ] **Step 1: Add auth middleware tests**
|
- [ ] **Step 1: Add auth middleware tests**
|
||||||
@@ -400,7 +400,7 @@ def test_scope_validation():
|
|||||||
async def test_bearer_token_path_sets_g_user(monkeypatch):
|
async def test_bearer_token_path_sets_g_user(monkeypatch):
|
||||||
"""Valid bearer token authenticates and sets g.user and g.api_key."""
|
"""Valid bearer token authenticates and sets g.user and g.api_key."""
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from fabledassistant.auth import _check_auth
|
from scribe.auth import _check_auth
|
||||||
|
|
||||||
# Mock ApiKey object
|
# Mock ApiKey object
|
||||||
fake_key = MagicMock()
|
fake_key = MagicMock()
|
||||||
@@ -411,8 +411,8 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
|
|||||||
fake_user = MagicMock()
|
fake_user = MagicMock()
|
||||||
fake_user.role = "user"
|
fake_user.role = "user"
|
||||||
|
|
||||||
monkeypatch.setattr("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key))
|
monkeypatch.setattr("scribe.auth.lookup_key", AsyncMock(return_value=fake_key))
|
||||||
monkeypatch.setattr("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user))
|
monkeypatch.setattr("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user))
|
||||||
|
|
||||||
called_with_user = {}
|
called_with_user = {}
|
||||||
|
|
||||||
@@ -434,7 +434,7 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
|
|||||||
async with app.test_request_context("/test", method="GET",
|
async with app.test_request_context("/test", method="GET",
|
||||||
headers={"Authorization": "Bearer fmcp_valid"}):
|
headers={"Authorization": "Bearer fmcp_valid"}):
|
||||||
# Just verify _check_auth calls lookup_key with the right token
|
# Just verify _check_auth calls lookup_key with the right token
|
||||||
import fabledassistant.auth as auth_module
|
import scribe.auth as auth_module
|
||||||
auth_module.lookup_key.assert_called_with # callable
|
auth_module.lookup_key.assert_called_with # callable
|
||||||
|
|
||||||
|
|
||||||
@@ -442,7 +442,7 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
|
|||||||
async def test_read_only_key_blocked_on_post():
|
async def test_read_only_key_blocked_on_post():
|
||||||
"""Read-only API key returns 403 on non-GET requests."""
|
"""Read-only API key returns 403 on non-GET requests."""
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from fabledassistant.auth import _check_auth
|
from scribe.auth import _check_auth
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
|
|
||||||
fake_key = MagicMock()
|
fake_key = MagicMock()
|
||||||
@@ -459,8 +459,8 @@ async def test_read_only_key_blocked_on_post():
|
|||||||
headers={"Authorization": "Bearer fmcp_readonly"}),
|
headers={"Authorization": "Bearer fmcp_readonly"}),
|
||||||
):
|
):
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
with patch("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)), \
|
with patch("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)), \
|
||||||
patch("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
|
patch("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
|
||||||
|
|
||||||
async def dummy():
|
async def dummy():
|
||||||
return "ok"
|
return "ok"
|
||||||
@@ -481,15 +481,15 @@ docker compose run --rm app pytest tests/test_api_keys.py -v
|
|||||||
|
|
||||||
- [ ] **Step 3: Update auth.py**
|
- [ ] **Step 3: Update auth.py**
|
||||||
|
|
||||||
Replace `src/fabledassistant/auth.py` with:
|
Replace `src/scribe/auth.py` with:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import functools
|
import functools
|
||||||
|
|
||||||
from quart import g, jsonify, request, session
|
from quart import g, jsonify, request, session
|
||||||
|
|
||||||
from fabledassistant.services.auth import get_user_by_id
|
from scribe.services.auth import get_user_by_id
|
||||||
from fabledassistant.services.api_keys import lookup_key
|
from scribe.services.api_keys import lookup_key
|
||||||
|
|
||||||
|
|
||||||
def _check_auth(f, required_role: str | None = None):
|
def _check_auth(f, required_role: str | None = None):
|
||||||
@@ -556,7 +556,7 @@ Expected: all existing tests still pass
|
|||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/fabledassistant/auth.py tests/test_api_keys.py
|
git add src/scribe/auth.py tests/test_api_keys.py
|
||||||
git commit -m "feat: add bearer token auth to _check_auth, falls back to session"
|
git commit -m "feat: add bearer token auth to _check_auth, falls back to session"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -565,18 +565,18 @@ git commit -m "feat: add bearer token auth to _check_auth, falls back to session
|
|||||||
### Task 4: API key routes + app registration
|
### Task 4: API key routes + app registration
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `src/fabledassistant/routes/api_keys.py`
|
- Create: `src/scribe/routes/api_keys.py`
|
||||||
- Modify: `src/fabledassistant/app.py`
|
- Modify: `src/scribe/app.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Write the routes**
|
- [ ] **Step 1: Write the routes**
|
||||||
|
|
||||||
Create `src/fabledassistant/routes/api_keys.py`:
|
Create `src/scribe/routes/api_keys.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from scribe.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key
|
from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key
|
||||||
|
|
||||||
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
|
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
|
||||||
|
|
||||||
@@ -619,10 +619,10 @@ async def revoke_key_route(key_id: int):
|
|||||||
|
|
||||||
- [ ] **Step 2: Register in app.py**
|
- [ ] **Step 2: Register in app.py**
|
||||||
|
|
||||||
In `src/fabledassistant/app.py`, add the import alongside the other route imports:
|
In `src/scribe/app.py`, add the import alongside the other route imports:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fabledassistant.routes.api_keys import api_keys_bp
|
from scribe.routes.api_keys import api_keys_bp
|
||||||
```
|
```
|
||||||
|
|
||||||
And add the registration line after `app.register_blueprint(users_bp)`:
|
And add the registration line after `app.register_blueprint(users_bp)`:
|
||||||
@@ -651,7 +651,7 @@ curl -s http://localhost:8080/api/auth/me \
|
|||||||
- [ ] **Step 4: Commit**
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/fabledassistant/routes/api_keys.py src/fabledassistant/app.py
|
git add src/scribe/routes/api_keys.py src/scribe/app.py
|
||||||
git commit -m "feat: add API key CRUD routes and register blueprint"
|
git commit -m "feat: add API key CRUD routes and register blueprint"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -660,14 +660,14 @@ git commit -m "feat: add API key CRUD routes and register blueprint"
|
|||||||
### Task 5: Conversation type wiring
|
### Task 5: Conversation type wiring
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `src/fabledassistant/services/chat.py` (lines 17-30 and 123-135)
|
- Modify: `src/scribe/services/chat.py` (lines 17-30 and 123-135)
|
||||||
- Modify: `src/fabledassistant/routes/chat.py` (lines 73-79)
|
- Modify: `src/scribe/routes/chat.py` (lines 73-79)
|
||||||
|
|
||||||
Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion.
|
Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion.
|
||||||
|
|
||||||
- [ ] **Step 1: Update `create_conversation` service**
|
- [ ] **Step 1: Update `create_conversation` service**
|
||||||
|
|
||||||
In `src/fabledassistant/services/chat.py`, change the function signature at line 17:
|
In `src/scribe/services/chat.py`, change the function signature at line 17:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async def create_conversation(
|
async def create_conversation(
|
||||||
@@ -692,7 +692,7 @@ async def create_conversation(
|
|||||||
|
|
||||||
- [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"**
|
- [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"**
|
||||||
|
|
||||||
In `src/fabledassistant/services/chat.py`, update the WHERE clause at line 130:
|
In `src/scribe/services/chat.py`, update the WHERE clause at line 130:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -708,7 +708,7 @@ result = await session.execute(
|
|||||||
|
|
||||||
- [ ] **Step 3: Update the POST route to accept conversation_type**
|
- [ ] **Step 3: Update the POST route to accept conversation_type**
|
||||||
|
|
||||||
In `src/fabledassistant/routes/chat.py`, update `create_conversation_route` (around line 73):
|
In `src/scribe/routes/chat.py`, update `create_conversation_route` (around line 73):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@chat_bp.route("/conversations", methods=["POST"])
|
@chat_bp.route("/conversations", methods=["POST"])
|
||||||
@@ -737,7 +737,7 @@ Expected: all tests pass
|
|||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/fabledassistant/services/chat.py src/fabledassistant/routes/chat.py
|
git add src/scribe/services/chat.py src/scribe/routes/chat.py
|
||||||
git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep"
|
git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -746,8 +746,8 @@ git commit -m "feat: wire conversation_type through create_conversation, exclude
|
|||||||
### Task 6: Semantic search endpoint
|
### Task 6: Semantic search endpoint
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `src/fabledassistant/routes/search.py`
|
- Create: `src/scribe/routes/search.py`
|
||||||
- Modify: `src/fabledassistant/app.py`
|
- Modify: `src/scribe/app.py`
|
||||||
- Create: `tests/test_search_route.py`
|
- Create: `tests/test_search_route.py`
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
- [ ] **Step 1: Write the failing test**
|
||||||
@@ -762,7 +762,7 @@ from unittest.mock import patch, AsyncMock
|
|||||||
|
|
||||||
def test_content_type_mapping():
|
def test_content_type_mapping():
|
||||||
"""Verify content_type string maps to correct is_task value."""
|
"""Verify content_type string maps to correct is_task value."""
|
||||||
from fabledassistant.routes.search import _content_type_to_is_task
|
from scribe.routes.search import _content_type_to_is_task
|
||||||
assert _content_type_to_is_task("note") is False
|
assert _content_type_to_is_task("note") is False
|
||||||
assert _content_type_to_is_task("task") is True
|
assert _content_type_to_is_task("task") is True
|
||||||
assert _content_type_to_is_task("all") is None
|
assert _content_type_to_is_task("all") is None
|
||||||
@@ -779,13 +779,13 @@ Expected: `ImportError` (module doesn't exist yet)
|
|||||||
|
|
||||||
- [ ] **Step 3: Write the route**
|
- [ ] **Step 3: Write the route**
|
||||||
|
|
||||||
Create `src/fabledassistant/routes/search.py`:
|
Create `src/scribe/routes/search.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from scribe.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.services.embeddings import semantic_search_notes
|
from scribe.services.embeddings import semantic_search_notes
|
||||||
|
|
||||||
search_bp = Blueprint("search", __name__, url_prefix="/api/search")
|
search_bp = Blueprint("search", __name__, url_prefix="/api/search")
|
||||||
|
|
||||||
@@ -834,9 +834,9 @@ Note: check `services/embeddings.py` to confirm the return type of `semantic_sea
|
|||||||
|
|
||||||
- [ ] **Step 4: Register in app.py**
|
- [ ] **Step 4: Register in app.py**
|
||||||
|
|
||||||
Add to imports in `src/fabledassistant/app.py`:
|
Add to imports in `src/scribe/app.py`:
|
||||||
```python
|
```python
|
||||||
from fabledassistant.routes.search import search_bp
|
from scribe.routes.search import search_bp
|
||||||
```
|
```
|
||||||
|
|
||||||
Add registration:
|
Add registration:
|
||||||
@@ -855,8 +855,8 @@ Expected: all tests pass
|
|||||||
- [ ] **Step 6: Commit**
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/fabledassistant/routes/search.py \
|
git add src/scribe/routes/search.py \
|
||||||
src/fabledassistant/app.py \
|
src/scribe/app.py \
|
||||||
tests/test_search_route.py
|
tests/test_search_route.py
|
||||||
git commit -m "feat: add GET /api/search semantic search endpoint"
|
git commit -m "feat: add GET /api/search semantic search endpoint"
|
||||||
```
|
```
|
||||||
@@ -1905,7 +1905,7 @@ async def send_message(
|
|||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: verify the Fable message POST endpoint path by checking `src/fabledassistant/routes/chat.py` — search for the route that accepts a user message and triggers generation. Adjust `/api/chat/conversations/{conv_id}/messages` if the actual path differs.
|
Note: verify the Fable message POST endpoint path by checking `src/scribe/routes/chat.py` — search for the route that accepts a user message and triggers generation. Adjust `/api/chat/conversations/{conv_id}/messages` if the actual path differs.
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests**
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
|||||||
@@ -39,20 +39,20 @@
|
|||||||
|
|
||||||
## New Backend Files
|
## New Backend Files
|
||||||
|
|
||||||
### `src/fabledassistant/services/stt.py`
|
### `src/scribe/services/stt.py`
|
||||||
Lazy singleton `WhisperModel` loader. Public API:
|
Lazy singleton `WhisperModel` loader. Public API:
|
||||||
- `load_stt_model()` — called at startup via `asyncio.create_task`
|
- `load_stt_model()` — called at startup via `asyncio.create_task`
|
||||||
- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text
|
- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text
|
||||||
- `stt_available() -> bool`
|
- `stt_available() -> bool`
|
||||||
|
|
||||||
### `src/fabledassistant/services/tts.py`
|
### `src/scribe/services/tts.py`
|
||||||
Lazy singleton `KPipeline` loader. Public API:
|
Lazy singleton `KPipeline` loader. Public API:
|
||||||
- `load_tts_model()` — called at startup
|
- `load_tts_model()` — called at startup
|
||||||
- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono)
|
- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono)
|
||||||
- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels
|
- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels
|
||||||
- `tts_available() -> bool`
|
- `tts_available() -> bool`
|
||||||
|
|
||||||
### `src/fabledassistant/routes/voice.py`
|
### `src/scribe/routes/voice.py`
|
||||||
Blueprint at `/api/voice`, all routes `@login_required`.
|
Blueprint at `/api/voice`, all routes `@login_required`.
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
@@ -66,27 +66,27 @@ Blueprint at `/api/voice`, all routes `@login_required`.
|
|||||||
|
|
||||||
## Modified Backend Files
|
## Modified Backend Files
|
||||||
|
|
||||||
### `src/fabledassistant/app.py`
|
### `src/scribe/app.py`
|
||||||
- Register `voice_bp` blueprint
|
- Register `voice_bp` blueprint
|
||||||
- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED`
|
- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED`
|
||||||
|
|
||||||
### `src/fabledassistant/config.py`
|
### `src/scribe/config.py`
|
||||||
- Add 4 new env var attributes
|
- Add 4 new env var attributes
|
||||||
- Add validation in `validate()`
|
- Add validation in `validate()`
|
||||||
|
|
||||||
### `src/fabledassistant/services/llm.py`
|
### `src/scribe/services/llm.py`
|
||||||
- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()`
|
- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()`
|
||||||
- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."*
|
- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."*
|
||||||
- Append style modifier based on `voice_speech_style`
|
- Append style modifier based on `voice_speech_style`
|
||||||
|
|
||||||
### `src/fabledassistant/services/generation_task.py`
|
### `src/scribe/services/generation_task.py`
|
||||||
- Add `voice_mode: bool = False` to `run_generation()`
|
- Add `voice_mode: bool = False` to `run_generation()`
|
||||||
- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()`
|
- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()`
|
||||||
|
|
||||||
### `src/fabledassistant/routes/chat.py`
|
### `src/scribe/routes/chat.py`
|
||||||
- Allow `"voice"` in `conversation_type` whitelist
|
- Allow `"voice"` in `conversation_type` whitelist
|
||||||
|
|
||||||
### `src/fabledassistant/services/chat.py`
|
### `src/scribe/services/chat.py`
|
||||||
- Exclude `conversation_type == "voice"` from auto-cleanup retention
|
- Exclude `conversation_type == "voice"` from auto-cleanup retention
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
# Android Companion App
|
|
||||||
|
|
||||||
The Android companion app lives in a separate repository at `/home/bvandeusen/Nextcloud/Projects/fabled_app`.
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- Flutter + Dart
|
|
||||||
- Riverpod (state management)
|
|
||||||
- GoRouter (navigation)
|
|
||||||
- Dio (HTTP client)
|
|
||||||
- PersistCookieJar (session persistence)
|
|
||||||
- SSE streaming via `fetch` + `ReadableStream` bridge
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
lib/
|
|
||||||
app.dart # GoRouter + _Shell + _QuickCaptureBar
|
|
||||||
core/constants.dart # Routes.*
|
|
||||||
data/
|
|
||||||
models/ # note.dart, task.dart, project.dart
|
|
||||||
api/ # notes_api.dart, tasks_api.dart, projects_api.dart
|
|
||||||
repositories/ # notes, tasks, projects repositories
|
|
||||||
providers/
|
|
||||||
api_client_provider.dart # all API + repository providers
|
|
||||||
notes_provider.dart # NotesNotifier
|
|
||||||
tasks_provider.dart # TasksNotifier
|
|
||||||
projects_provider.dart # ProjectsNotifier
|
|
||||||
screens/
|
|
||||||
notes/note_edit_screen.dart # chip tag input + ProjectSelector
|
|
||||||
tasks/task_edit_screen.dart # ProjectSelector
|
|
||||||
projects/project_list_screen.dart
|
|
||||||
widgets/
|
|
||||||
project_selector.dart # reusable DropdownButtonFormField
|
|
||||||
```
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
4-tab shell (Notes · Tasks · Projects · Chat):
|
|
||||||
- Phone: bottom `NavigationBar`
|
|
||||||
- Tablet/landscape: `NavigationRail`
|
|
||||||
|
|
||||||
Quick Capture bar persists across all tabs. Settings accessible from top-right icon.
|
|
||||||
|
|
||||||
## Feature Status
|
|
||||||
|
|
||||||
| Feature | Status | Notes |
|
|
||||||
|---------|--------|-------|
|
|
||||||
| Notes CRUD | ✅ | Tags chip input; project selector in editor |
|
|
||||||
| Tasks CRUD | ✅ | Project selector in editor |
|
|
||||||
| Projects list | ✅ | Active/archived sections; long-press status change; create dialog |
|
|
||||||
| Chat + SSE | ✅ | Full streaming |
|
|
||||||
| Quick Capture | ✅ | Offline queue with retry |
|
|
||||||
| Tags | ✅ | Chip input in NoteEditScreen; typed as `List<String>` |
|
|
||||||
| Project assignment | ✅ | `ProjectSelector` dropdown in Note + Task editors |
|
|
||||||
| Milestones | ❌ deferred | Too granular for mobile; web UI handles it |
|
|
||||||
| Push notifications | ❌ incompatible | Backend uses browser VAPID; Flutter needs FCM/APNs — separate implementation required |
|
|
||||||
| CalDAV settings | ❌ intentional | Server-side config only; not exposed in mobile app |
|
|
||||||
|
|
||||||
## API Compatibility Notes
|
|
||||||
|
|
||||||
- `GET /api/projects/:id` returns a flat JSON object (not `{project: ...}` wrapper); includes `summary` field.
|
|
||||||
- `POST /api/projects` returns the project dict directly (201).
|
|
||||||
- `PATCH /api/projects/:id` returns the updated project dict.
|
|
||||||
- Task body field is `body` (not `description`) — the app maps `description` → `body` on serialize.
|
|
||||||
|
|
||||||
## Self-Update
|
|
||||||
|
|
||||||
The app supports self-update via the Forgejo release API (`update_provider.dart`). It checks the latest release tag and prompts the user to download and install a new APK when one is available.
|
|
||||||
|
|
||||||
## CI
|
|
||||||
|
|
||||||
Builds are triggered from the Forgejo Actions pipeline in the `fabled_app` repository. The APK is attached to the release as a downloadable artifact.
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
│ Docker Compose │
|
│ Docker Compose │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────────────┐ ┌────────────┐ │
|
│ ┌──────────────────────┐ ┌────────────┐ │
|
||||||
│ │ fabledassistant │ │ ollama │ │
|
│ │ scribe │ │ ollama │ │
|
||||||
│ │ ┌────────────────┐ │ │ │ │
|
│ │ ┌────────────────┐ │ │ │ │
|
||||||
│ │ │ Quart Server │ │ │ LLM API │ │
|
│ │ │ Quart Server │ │ │ LLM API │ │
|
||||||
│ │ │ ┌──────────┐ │ │ │ │ │
|
│ │ │ ┌──────────┐ │ │ │ │ │
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
fabledassistant/
|
scribe/
|
||||||
├── docker-compose.yml # Development stack
|
├── docker-compose.yml # Development stack
|
||||||
├── docker-compose.prod.yml # Production stack (Docker Swarm)
|
├── docker-compose.prod.yml # Production stack (Docker Swarm)
|
||||||
├── Dockerfile # Multi-stage build (Node → Python)
|
├── Dockerfile # Multi-stage build (Node → Python)
|
||||||
@@ -54,7 +54,7 @@ fabledassistant/
|
|||||||
│ ├── server.py # FastMCP tool registrations
|
│ ├── server.py # FastMCP tool registrations
|
||||||
│ ├── client.py # FableClient (httpx wrapper)
|
│ ├── client.py # FableClient (httpx wrapper)
|
||||||
│ └── tools/ # Tool modules (notes, tasks, projects, …)
|
│ └── tools/ # Tool modules (notes, tasks, projects, …)
|
||||||
├── src/fabledassistant/
|
├── src/scribe/
|
||||||
│ ├── app.py # Quart app factory + blueprint registration
|
│ ├── app.py # Quart app factory + blueprint registration
|
||||||
│ ├── config.py # Config class (reads env vars)
|
│ ├── config.py # Config class (reads env vars)
|
||||||
│ ├── auth.py # login_required decorator, session checks
|
│ ├── auth.py # login_required decorator, session checks
|
||||||
@@ -169,7 +169,7 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
|
|||||||
|
|
||||||
## Detailed File Reference
|
## Detailed File Reference
|
||||||
|
|
||||||
### Backend (`src/fabledassistant/`)
|
### Backend (`src/scribe/`)
|
||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|------|---------------|
|
|------|---------------|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Configuration is via environment variables. The `docker-compose.yml` file sets d
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/fabledassistant` | PostgreSQL async connection string |
|
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/scribe` | PostgreSQL async connection string |
|
||||||
| `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** |
|
| `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** |
|
||||||
| `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) |
|
| `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) |
|
||||||
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
|
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
|
||||||
@@ -91,7 +91,7 @@ The production compose file adds:
|
|||||||
```bash
|
```bash
|
||||||
# Create Docker secrets
|
# Create Docker secrets
|
||||||
echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key -
|
echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key -
|
||||||
echo "postgresql+asyncpg://fabled:strongpassword@db/fabledassistant" | docker secret create fabled_db_url -
|
echo "postgresql+asyncpg://fabled:strongpassword@db/scribe" | docker secret create fabled_db_url -
|
||||||
|
|
||||||
# Deploy
|
# Deploy
|
||||||
docker stack deploy -c docker-compose.prod.yml fabled
|
docker stack deploy -c docker-compose.prod.yml fabled
|
||||||
|
|||||||
@@ -568,17 +568,6 @@ Items deliberately not addressed in this round; revisit when a real need surface
|
|||||||
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
|
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
|
||||||
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
|
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
|
||||||
|
|
||||||
### Flutter app port — shipped 2026-04-28
|
|
||||||
|
|
||||||
The companion mobile app (`fabled_app` / FabledApp repo) tracks the same design system. Two commits:
|
|
||||||
|
|
||||||
- **Foundation port** — `0f05f47`. `lib/core/theme.dart` rewritten with the Obsidian/Iron/Pewter dark palette, warm parchment light palette, dusty violet `#5B4A8A` primary. Inter loaded for body, JetBrains Mono available at call sites, Fraunces for headlines ≥18px. New `ActionColors` ThemeExtension exposes Moss/Bronze/Oxblood/Pewter outside the `ColorScheme` (Material's primary/secondary/tertiary slots all carry brand accent, so action tokens need their own home). `GradientButton` recolored to dusty-violet gradient.
|
|
||||||
- **Surface phase** — `b9e68e3`. `lucide_icons ^0.257.0` installed; 107 `Icons.*` references across 21 files swapped to `LucideIcons.*`. Input border radius 24 → 8 in both themes. ChatMessageBubble Illuminated Transcript fixes — neutral border on user bubbles, `surface`/Iron bg on assistant bubbles, asymmetric corner restoration (only bottom-left clipped, not both left corners), accent-tinted glow shadow added. 5 destructive confirm buttons across notes / tasks / chat / calendar wired to `ActionColors.destructive`. Calendar event Save wired to `ActionColors.primary` as the reference Moss site. 4 hardcoded indigo Color literals → dusty-violet equivalents.
|
|
||||||
|
|
||||||
The Flutter port doesn't decompose into 7 PRs the way web did because Flutter's centralized `theme.dart` means most palette/font work happens in one file. Per-screen Save / Cancel reclassification beyond the calendar event Save is opportunistic — the wiring pattern (`Theme.of(context).extension<ActionColors>()!.primary`) is established and applied incrementally as files are touched.
|
|
||||||
|
|
||||||
Pattern reference for downstream screens: see `lib/screens/calendar/event_form_sheet.dart` for `ActionColors.primary` usage on Save buttons; see the dialog spots in `note_edit_screen.dart` / `task_edit_screen.dart` / `note_detail_screen.dart` / `conversations_tab_screen.dart` for `ActionColors.destructive` on confirm-Delete buttons.
|
|
||||||
|
|
||||||
### Open threads
|
### Open threads
|
||||||
|
|
||||||
*New threads will accumulate here as gaps surface in real use.*
|
*New threads will accumulate here as gaps surface in real use.*
|
||||||
|
|||||||
+3
-3
@@ -93,7 +93,7 @@ config on the runner host.
|
|||||||
|
|
||||||
### Docker Registry
|
### Docker Registry
|
||||||
|
|
||||||
Images pushed to: `git.fabledsword.com/bvandeusen/fabledassistant`
|
Images pushed to: `git.fabledsword.com/bvandeusen/scribe`
|
||||||
Cache tag: `:cache` (reduces build time ~80%)
|
Cache tag: `:cache` (reduces build time ~80%)
|
||||||
|
|
||||||
Required secrets (repo → Settings → Secrets → Actions):
|
Required secrets (repo → Settings → Secrets → Actions):
|
||||||
@@ -140,8 +140,8 @@ Current migration sequence (all idempotent raw SQL):
|
|||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
- Services: `async with async_session() as session:` — import from `fabledassistant.models`
|
- Services: `async with async_session() as session:` — import from `scribe.models`
|
||||||
- No `fabledassistant.database` module
|
- No `scribe.database` module
|
||||||
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
|
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
|
||||||
- All business logic in `services/`; routes are thin wrappers
|
- All business logic in `services/`; routes are thin wrappers
|
||||||
- Permission checks via `services/access.py` — never inline ownership checks in routes
|
- Permission checks via `services/access.py` — never inline ownership checks in routes
|
||||||
|
|||||||
@@ -132,8 +132,6 @@ Settings are tabbed:
|
|||||||
|
|
||||||
- Email integration (read/send via IMAP/SMTP tools in chat)
|
- Email integration (read/send via IMAP/SMTP tools in chat)
|
||||||
- Session invalidation on user deletion
|
- Session invalidation on user deletion
|
||||||
- Flutter push notifications (requires FCM/APNs — separate from web VAPID)
|
|
||||||
- Flutter milestone support in project view
|
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "fabledassistant-frontend",
|
"name": "scribe-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "fabledassistant-frontend",
|
"name": "scribe-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fullcalendar/core": "^6.1.20",
|
"@fullcalendar/core": "^6.1.20",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "fabledassistant-frontend",
|
"name": "scribe-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface Rulebook {
|
|||||||
owner_user_id: number;
|
owner_user_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
always_on: boolean;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -21,7 +22,8 @@ export interface RulebookTopic {
|
|||||||
|
|
||||||
export interface Rule {
|
export interface Rule {
|
||||||
id: number;
|
id: number;
|
||||||
topic_id: number;
|
topic_id: number | null;
|
||||||
|
project_id: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
why: string;
|
why: string;
|
||||||
@@ -35,7 +37,7 @@ export interface RuleHeader {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
topic_id: number;
|
topic_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplicableRules {
|
export interface ApplicableRules {
|
||||||
@@ -43,7 +45,28 @@ export interface ApplicableRules {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
|
topic_id: number;
|
||||||
topic_title: string;
|
topic_title: string;
|
||||||
|
rulebook_id: number;
|
||||||
|
rulebook_title: string;
|
||||||
|
}[];
|
||||||
|
project_rules: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
statement: string;
|
||||||
|
}[];
|
||||||
|
suppressed_rules: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
topic_id: number;
|
||||||
|
topic_title: string;
|
||||||
|
rulebook_id: number;
|
||||||
|
rulebook_title: string;
|
||||||
|
}[];
|
||||||
|
suppressed_topics: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
rulebook_id: number;
|
||||||
rulebook_title: string;
|
rulebook_title: string;
|
||||||
}[];
|
}[];
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
@@ -65,7 +88,7 @@ export async function createRulebook(data: { title: string; description?: string
|
|||||||
return apiPost("/api/rulebooks", data);
|
return apiPost("/api/rulebooks", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise<Rulebook> {
|
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise<Rulebook> {
|
||||||
return apiPatch(`/api/rulebooks/${id}`, data);
|
return apiPatch(`/api/rulebooks/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,3 +156,28 @@ export async function unsubscribeProject(projectId: number, rulebookId: number):
|
|||||||
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
|
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
|
||||||
return apiGet(`/api/projects/${projectId}/rules`);
|
return apiGet(`/api/projects/${projectId}/rules`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createProjectRule(
|
||||||
|
projectId: number,
|
||||||
|
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
|
||||||
|
): Promise<Rule> {
|
||||||
|
return apiPost(`/api/projects/${projectId}/rules`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Suppressions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
|
||||||
|
await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
|
||||||
|
return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function suppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||||
|
await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||||
|
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,13 +39,14 @@ router.afterEach(() => {
|
|||||||
<!-- Left: brand -->
|
<!-- Left: brand -->
|
||||||
<router-link to="/" class="nav-brand">
|
<router-link to="/" class="nav-brand">
|
||||||
<AppLogo :size="34" />
|
<AppLogo :size="34" />
|
||||||
<span class="brand-text">Fabled</span>
|
<span class="brand-text">Scribe</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<!-- Center: primary navigation (desktop) -->
|
<!-- Center: primary navigation (desktop) -->
|
||||||
<div class="nav-center">
|
<div class="nav-center">
|
||||||
<div class="nav-pill-bar">
|
<div class="nav-pill-bar">
|
||||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||||
|
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
@@ -90,7 +91,8 @@ router.afterEach(() => {
|
|||||||
|
|
||||||
<!-- Mobile dropdown -->
|
<!-- Mobile dropdown -->
|
||||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
|
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||||
|
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ const typeIcon: Record<string, string> = {
|
|||||||
project_shared: '📁',
|
project_shared: '📁',
|
||||||
note_shared: '📝',
|
note_shared: '📝',
|
||||||
group_added: '👥',
|
group_added: '👥',
|
||||||
|
event_reminder: '⏰',
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifMessage(n: { type: string; payload: Record<string, unknown> }): string {
|
||||||
|
const p = n.payload
|
||||||
|
switch (n.type) {
|
||||||
|
case 'project_shared':
|
||||||
|
return ` shared "${p.project_title}" with you as ${p.permission}`
|
||||||
|
case 'note_shared':
|
||||||
|
return ` shared "${p.note_title}" with you as ${p.permission}`
|
||||||
|
case 'group_added':
|
||||||
|
return ` added you to "${p.group_name}" as ${p.role}`
|
||||||
|
case 'event_reminder':
|
||||||
|
return `Reminder: "${p.title}" is coming up`
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClick(notif: { id: number; payload: Record<string, unknown> }) {
|
async function handleClick(notif: { id: number; payload: Record<string, unknown> }) {
|
||||||
@@ -49,11 +66,7 @@ onMounted(() => store.fetchAll())
|
|||||||
<div class="notif-body">
|
<div class="notif-body">
|
||||||
<p class="notif-msg">
|
<p class="notif-msg">
|
||||||
<strong v-if="n.payload.invited_by">{{ n.payload.invited_by }}</strong>
|
<strong v-if="n.payload.invited_by">{{ n.payload.invited_by }}</strong>
|
||||||
{{ n.type === 'project_shared'
|
{{ notifMessage(n) }}
|
||||||
? ` shared "${n.payload.project_title}" with you as ${n.payload.permission}`
|
|
||||||
: n.type === 'note_shared'
|
|
||||||
? ` shared "${n.payload.note_title}" with you as ${n.payload.permission}`
|
|
||||||
: ` added you to "${n.payload.group_name}" as ${n.payload.role}` }}
|
|
||||||
</p>
|
</p>
|
||||||
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { ref, onMounted, watch } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
getProjectApplicableRules, subscribeProject, unsubscribeProject,
|
getProjectApplicableRules, subscribeProject, unsubscribeProject,
|
||||||
listRulebooks, getRule,
|
listRulebooks, getRule, createProjectRule, deleteRule,
|
||||||
|
suppressRuleForProject, unsuppressRuleForProject,
|
||||||
|
suppressTopicForProject, unsuppressTopicForProject,
|
||||||
} from "@/api/rulebooks";
|
} from "@/api/rulebooks";
|
||||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||||
|
|
||||||
@@ -16,6 +18,9 @@ const expandedRuleIds = ref<Set<number>>(new Set());
|
|||||||
|
|
||||||
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
||||||
|
|
||||||
|
const showProjectRuleForm = ref(false);
|
||||||
|
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" });
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
applicable.value = await getProjectApplicableRules(props.projectId);
|
applicable.value = await getProjectApplicableRules(props.projectId);
|
||||||
}
|
}
|
||||||
@@ -59,18 +64,74 @@ function openInRulesView(rulebookId: number, ruleId?: number) {
|
|||||||
router.push({ path: "/rules", query });
|
router.push({ path: "/rules", query });
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]) {
|
interface TopicGroup {
|
||||||
const grouped: Record<string, Record<string, ApplicableRules["rules"]>> = {};
|
topic_id: number;
|
||||||
for (const r of rules) {
|
topic_title: string;
|
||||||
if (!grouped[r.rulebook_title]) grouped[r.rulebook_title] = {};
|
rules: ApplicableRules["rules"];
|
||||||
if (!grouped[r.rulebook_title][r.topic_title]) grouped[r.rulebook_title][r.topic_title] = [];
|
|
||||||
grouped[r.rulebook_title][r.topic_title].push(r);
|
|
||||||
}
|
}
|
||||||
return grouped;
|
interface RulebookGroup {
|
||||||
|
rulebook_id: number;
|
||||||
|
rulebook_title: string;
|
||||||
|
topics: TopicGroup[];
|
||||||
|
}
|
||||||
|
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
|
||||||
|
const byRulebook = new Map<number, RulebookGroup>();
|
||||||
|
for (const r of rules) {
|
||||||
|
let rb = byRulebook.get(r.rulebook_id);
|
||||||
|
if (!rb) {
|
||||||
|
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
|
||||||
|
byRulebook.set(r.rulebook_id, rb);
|
||||||
|
}
|
||||||
|
let topic = rb.topics.find((t) => t.topic_id === r.topic_id);
|
||||||
|
if (!topic) {
|
||||||
|
topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] };
|
||||||
|
rb.topics.push(topic);
|
||||||
|
}
|
||||||
|
topic.rules.push(r);
|
||||||
|
}
|
||||||
|
return Array.from(byRulebook.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
function rulebookIdForTitle(title: string): number | undefined {
|
async function submitProjectRule() {
|
||||||
return applicable.value?.subscribed_rulebooks.find((rb) => rb.title === title)?.id;
|
const statement = newProjectRule.value.statement.trim();
|
||||||
|
if (!statement) return;
|
||||||
|
await createProjectRule(props.projectId, {
|
||||||
|
statement,
|
||||||
|
title: newProjectRule.value.title.trim() || undefined,
|
||||||
|
why: newProjectRule.value.why.trim() || undefined,
|
||||||
|
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
||||||
|
});
|
||||||
|
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" };
|
||||||
|
showProjectRuleForm.value = false;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProjectRule(ruleId: number) {
|
||||||
|
if (!confirm("Delete this project rule? It will move to the trash.")) return;
|
||||||
|
await deleteRule(ruleId);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSuppressed = ref(false);
|
||||||
|
|
||||||
|
async function suppressRule(ruleId: number) {
|
||||||
|
await suppressRuleForProject(props.projectId, ruleId);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsuppressRule(ruleId: number) {
|
||||||
|
await unsuppressRuleForProject(props.projectId, ruleId);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suppressTopic(topicId: number) {
|
||||||
|
await suppressTopicForProject(props.projectId, topicId);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsuppressTopic(topicId: number) {
|
||||||
|
await unsuppressTopicForProject(props.projectId, topicId);
|
||||||
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -111,26 +172,103 @@ watch(() => props.projectId, load);
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="applicable">
|
<section class="project-rules">
|
||||||
<h3>Applicable rules</h3>
|
<div class="section-head">
|
||||||
<p v-if="applicable.rules.length === 0" class="empty">
|
<h3>Project rules</h3>
|
||||||
No rules yet — subscribe to a rulebook above, or create one at
|
<button
|
||||||
<a @click="router.push('/rules')">Rulebooks</a>.
|
v-if="!showProjectRuleForm"
|
||||||
</p>
|
class="add"
|
||||||
<div
|
@click="showProjectRuleForm = true"
|
||||||
v-for="(topics, rbTitle) in groupByRulebookAndTopic(applicable.rules)"
|
|
||||||
:key="rbTitle"
|
|
||||||
class="rb-group"
|
|
||||||
>
|
>
|
||||||
<h4>{{ rbTitle }}</h4>
|
+ New project rule
|
||||||
<div v-for="(rules, topicTitle) in topics" :key="topicTitle" class="topic-group">
|
</button>
|
||||||
<h5>{{ topicTitle }}</h5>
|
</div>
|
||||||
<ul>
|
<form v-if="showProjectRuleForm" class="new-rule-form" @submit.prevent="submitProjectRule">
|
||||||
<li v-for="r in rules" :key="r.id" class="rule">
|
<input
|
||||||
|
v-model="newProjectRule.title"
|
||||||
|
placeholder="Title (optional — derived from statement if blank)"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
v-model="newProjectRule.statement"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<textarea
|
||||||
|
v-model="newProjectRule.why"
|
||||||
|
placeholder="Why (optional) — the rationale"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<textarea
|
||||||
|
v-model="newProjectRule.how_to_apply"
|
||||||
|
placeholder="How to apply (optional) — when / where it kicks in"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<div class="form-buttons">
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
<button type="button" @click="showProjectRuleForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<ul v-if="applicable.project_rules && applicable.project_rules.length > 0" class="rule-list">
|
||||||
|
<li v-for="r in applicable.project_rules" :key="r.id" class="rule">
|
||||||
<div class="rule-head" @click="toggleRuleExpand(r.id)">
|
<div class="rule-head" @click="toggleRuleExpand(r.id)">
|
||||||
<span class="rule-title">{{ r.title }}</span>
|
<span class="rule-title">{{ r.title }}</span>
|
||||||
<span class="rule-statement">{{ r.statement }}</span>
|
<span class="rule-statement">{{ r.statement }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
|
||||||
|
<div v-if="ruleDetails[r.id].why">
|
||||||
|
<strong>Why:</strong> {{ ruleDetails[r.id].why }}
|
||||||
|
</div>
|
||||||
|
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||||
|
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||||
|
</div>
|
||||||
|
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
v-else-if="!showProjectRuleForm"
|
||||||
|
class="empty"
|
||||||
|
>
|
||||||
|
No project-only rules yet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="applicable">
|
||||||
|
<h3>Applicable rules</h3>
|
||||||
|
<p v-if="applicable.rules.length === 0" class="empty">
|
||||||
|
No rules yet — subscribe to a rulebook above, or create one at
|
||||||
|
<a @click="router.push('/rules')">Rulebooks</a>.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="rb in groupByRulebookAndTopic(applicable.rules)"
|
||||||
|
:key="rb.rulebook_id"
|
||||||
|
class="rb-group"
|
||||||
|
>
|
||||||
|
<h4>{{ rb.rulebook_title }}</h4>
|
||||||
|
<div v-for="topic in rb.topics" :key="topic.topic_id" class="topic-group">
|
||||||
|
<h5>
|
||||||
|
<span>{{ topic.topic_title }}</span>
|
||||||
|
<button
|
||||||
|
class="skip-btn"
|
||||||
|
:title="`Skip the entire ${topic.topic_title} topic for this project`"
|
||||||
|
@click="suppressTopic(topic.topic_id)"
|
||||||
|
>× skip topic</button>
|
||||||
|
</h5>
|
||||||
|
<ul>
|
||||||
|
<li v-for="r in topic.rules" :key="r.id" class="rule">
|
||||||
|
<div class="rule-head">
|
||||||
|
<div class="rule-head-text" @click="toggleRuleExpand(r.id)">
|
||||||
|
<span class="rule-title">{{ r.title }}</span>
|
||||||
|
<span class="rule-statement">{{ r.statement }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="skip-btn"
|
||||||
|
title="Skip this rule for this project"
|
||||||
|
@click.stop="suppressRule(r.id)"
|
||||||
|
>× skip</button>
|
||||||
|
</div>
|
||||||
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
|
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
|
||||||
<div v-if="ruleDetails[r.id].why">
|
<div v-if="ruleDetails[r.id].why">
|
||||||
<strong>Why:</strong> {{ ruleDetails[r.id].why }}
|
<strong>Why:</strong> {{ ruleDetails[r.id].why }}
|
||||||
@@ -140,7 +278,7 @@ watch(() => props.projectId, load);
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="edit-link"
|
class="edit-link"
|
||||||
@click="rulebookIdForTitle(String(rbTitle)) && openInRulesView(rulebookIdForTitle(String(rbTitle))!, r.id)"
|
@click="openInRulesView(r.rulebook_id, r.id)"
|
||||||
>
|
>
|
||||||
Edit in Rulebook →
|
Edit in Rulebook →
|
||||||
</button>
|
</button>
|
||||||
@@ -153,6 +291,32 @@ watch(() => props.projectId, load);
|
|||||||
Truncated at 50 rules — there are more applicable rules.
|
Truncated at 50 rules — there are more applicable rules.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="applicable.suppressed_rules.length + applicable.suppressed_topics.length > 0"
|
||||||
|
class="suppressed"
|
||||||
|
>
|
||||||
|
<button class="suppressed-toggle" @click="showSuppressed = !showSuppressed">
|
||||||
|
<span>Suppressed ({{ applicable.suppressed_rules.length + applicable.suppressed_topics.length }})</span>
|
||||||
|
<span class="caret">{{ showSuppressed ? "▾" : "▸" }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="showSuppressed" class="suppressed-body">
|
||||||
|
<ul v-if="applicable.suppressed_topics.length > 0" class="suppressed-list">
|
||||||
|
<li v-for="t in applicable.suppressed_topics" :key="`topic-${t.id}`">
|
||||||
|
<span class="suppressed-kind">topic</span>
|
||||||
|
<span class="suppressed-path">{{ t.rulebook_title }} → {{ t.title }}</span>
|
||||||
|
<button class="reenable-btn" @click="unsuppressTopic(t.id)">↻ re-enable</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul v-if="applicable.suppressed_rules.length > 0" class="suppressed-list">
|
||||||
|
<li v-for="r in applicable.suppressed_rules" :key="`rule-${r.id}`">
|
||||||
|
<span class="suppressed-kind">rule</span>
|
||||||
|
<span class="suppressed-path">{{ r.rulebook_title }} → {{ r.topic_title }} → {{ r.title }}</span>
|
||||||
|
<button class="reenable-btn" @click="unsuppressRule(r.id)">↻ re-enable</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -208,4 +372,67 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
}
|
}
|
||||||
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
||||||
.empty a { cursor: pointer; text-decoration: underline; }
|
.empty a { cursor: pointer; text-decoration: underline; }
|
||||||
|
.project-rules { margin-top: 1.5rem; }
|
||||||
|
.section-head { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.new-rule-form {
|
||||||
|
display: flex; flex-direction: column; gap: 0.5rem;
|
||||||
|
padding: 0.75rem; margin: 0.5rem 0;
|
||||||
|
background: var(--color-bg, #111113);
|
||||||
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
|
}
|
||||||
|
.new-rule-form input, .new-rule-form textarea {
|
||||||
|
background: var(--color-surface, #18181b); color: inherit;
|
||||||
|
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||||
|
padding: 0.5rem; font: inherit; resize: vertical;
|
||||||
|
}
|
||||||
|
.rule-list { margin-top: 0.5rem; }
|
||||||
|
.delete-link {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
|
||||||
|
}
|
||||||
|
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
|
||||||
|
.topic-group h5 {
|
||||||
|
display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.rule-head {
|
||||||
|
display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.rule-head-text { flex: 1; cursor: pointer; }
|
||||||
|
.skip-btn {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--color-muted, #888); font-size: 0.75rem;
|
||||||
|
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.topic-group h5:hover .skip-btn,
|
||||||
|
.rule:hover .skip-btn,
|
||||||
|
.skip-btn:focus { opacity: 1; }
|
||||||
|
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
|
||||||
|
/* Suppressed section */
|
||||||
|
.suppressed { margin-top: 1.5rem; }
|
||||||
|
.suppressed-toggle {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
font-size: 0.85rem; opacity: 0.7; padding: 0.25rem 0; color: inherit;
|
||||||
|
}
|
||||||
|
.suppressed-toggle:hover { opacity: 1; }
|
||||||
|
.suppressed-toggle .caret { font-size: 0.7em; }
|
||||||
|
.suppressed-body { margin-top: 0.5rem; }
|
||||||
|
.suppressed-list { padding-left: 0; }
|
||||||
|
.suppressed-list li {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0; opacity: 0.75;
|
||||||
|
}
|
||||||
|
.suppressed-kind {
|
||||||
|
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
padding: 0.1rem 0.4rem; border-radius: 3px;
|
||||||
|
background: var(--color-bg, #111113);
|
||||||
|
border: 1px solid var(--color-border, #2a2a2e);
|
||||||
|
}
|
||||||
|
.suppressed-path { flex: 1; }
|
||||||
|
.reenable-btn {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--color-primary, #6366f1); font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.reenable-btn:hover { text-decoration: underline; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from "vue";
|
import { ref, computed, onMounted, watch } from "vue";
|
||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +18,10 @@ const store = useRulebooksStore();
|
|||||||
const isCreating = ref(false);
|
const isCreating = ref(false);
|
||||||
const newTitle = ref("");
|
const newTitle = ref("");
|
||||||
|
|
||||||
|
const currentRulebook = computed(() =>
|
||||||
|
store.rulebooks.find((rb) => rb.id === props.rulebookId),
|
||||||
|
);
|
||||||
|
|
||||||
interface ProjectLite { id: number; title: string }
|
interface ProjectLite { id: number; title: string }
|
||||||
const projects = ref<ProjectLite[]>([]);
|
const projects = ref<ProjectLite[]>([]);
|
||||||
// Map<project_id, Set<rulebook_id>>
|
// Map<project_id, Set<rulebook_id>>
|
||||||
@@ -68,7 +72,17 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="pane">
|
<section class="pane">
|
||||||
<header><h2>Topics</h2></header>
|
<header>
|
||||||
|
<h2>Topics</h2>
|
||||||
|
<label v-if="currentRulebook" class="always-on-toggle" title="When on, rules from this rulebook load at session start regardless of project context">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="currentRulebook.always_on"
|
||||||
|
@change="store.toggleAlwaysOn(currentRulebook.id)"
|
||||||
|
/>
|
||||||
|
<span>Always on</span>
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
v-for="t in topics"
|
v-for="t in topics"
|
||||||
@@ -109,7 +123,14 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||||
|
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
|
.always-on-toggle {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.always-on-toggle input { cursor: pointer; }
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ async function submitNew() {
|
|||||||
@click="emit('select', rb.id)"
|
@click="emit('select', rb.id)"
|
||||||
>
|
>
|
||||||
<span class="title">{{ rb.title }}</span>
|
<span class="title">{{ rb.title }}</span>
|
||||||
|
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="new-rulebook">
|
<div class="new-rulebook">
|
||||||
@@ -50,9 +51,19 @@ async function submitNew() {
|
|||||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||||
|
.always-on-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-accent, rgba(91,74,138,0.25));
|
||||||
|
color: var(--color-accent-fg, inherit);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
.new-rulebook { margin-top: 1rem; }
|
.new-rulebook { margin-top: 1rem; }
|
||||||
.new-rulebook input {
|
.new-rulebook input {
|
||||||
width: 100%; margin-bottom: 0.5rem;
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ const router = createRouter({
|
|||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
// Knowledge is the landing page in the MCP-first architecture
|
// The dashboard ("what to work on") is the landing page; Knowledge
|
||||||
// (chat / journal / workspace surfaces have been removed).
|
// remains as the exhaustive "Browse" surface.
|
||||||
path: "/",
|
path: "/",
|
||||||
redirect: "/knowledge",
|
redirect: "/dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/dashboard",
|
||||||
|
name: "dashboard",
|
||||||
|
component: () => import("@/views/DashboardView.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/knowledge",
|
path: "/knowledge",
|
||||||
|
|||||||
@@ -2,66 +2,16 @@ import { ref } from "vue";
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import type { Note, NoteListResponse } from "@/types/note";
|
import type { Note } from "@/types/note";
|
||||||
|
|
||||||
|
// Single-note + mutation surface. The list/filter/sort/pagination surface that
|
||||||
|
// backed the removed /notes list view was dropped in the 2026-06-02 drift-audit
|
||||||
|
// cleanup (no consumers — KnowledgeView and the editors use single-entity and
|
||||||
|
// mutation methods only).
|
||||||
export const useNotesStore = defineStore("notes", () => {
|
export const useNotesStore = defineStore("notes", () => {
|
||||||
const notes = ref<Note[]>([]);
|
|
||||||
const currentNote = ref<Note | null>(null);
|
const currentNote = ref<Note | null>(null);
|
||||||
const total = ref(0);
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
// Filter / pagination / sort state
|
|
||||||
const activeTagFilters = ref<string[]>([]);
|
|
||||||
const limit = ref(20);
|
|
||||||
const offset = ref(0);
|
|
||||||
const sortField = ref("updated_at");
|
|
||||||
const sortOrder = ref<"asc" | "desc">("desc");
|
|
||||||
const searchQuery = ref("");
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
if (searchQuery.value) searchParams.set("q", searchQuery.value);
|
|
||||||
for (const t of activeTagFilters.value) {
|
|
||||||
searchParams.append("tag", t);
|
|
||||||
}
|
|
||||||
searchParams.set("sort", sortField.value);
|
|
||||||
searchParams.set("order", sortOrder.value);
|
|
||||||
searchParams.set("limit", String(limit.value));
|
|
||||||
searchParams.set("offset", String(offset.value));
|
|
||||||
|
|
||||||
const qs = searchParams.toString();
|
|
||||||
const data = await apiGet<NoteListResponse>(
|
|
||||||
`/api/notes${qs ? `?${qs}` : ""}`
|
|
||||||
);
|
|
||||||
notes.value = data.notes;
|
|
||||||
total.value = data.total;
|
|
||||||
} catch (e) {
|
|
||||||
useToastStore().show("Failed to load notes", "error");
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchNotes(params?: {
|
|
||||||
q?: string;
|
|
||||||
tag?: string[];
|
|
||||||
sort?: string;
|
|
||||||
order?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}) {
|
|
||||||
if (params?.q !== undefined) searchQuery.value = params.q || "";
|
|
||||||
if (params?.tag) activeTagFilters.value = params.tag;
|
|
||||||
if (params?.sort) sortField.value = params.sort;
|
|
||||||
if (params?.order) sortOrder.value = params.order as "asc" | "desc";
|
|
||||||
if (params?.limit) limit.value = params.limit;
|
|
||||||
if (params?.offset !== undefined) offset.value = params.offset;
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchNote(id: number) {
|
async function fetchNote(id: number) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -110,7 +60,6 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
async function deleteNote(id: number) {
|
async function deleteNote(id: number) {
|
||||||
try {
|
try {
|
||||||
await apiDelete(`/api/notes/${id}`);
|
await apiDelete(`/api/notes/${id}`);
|
||||||
notes.value = notes.value.filter((n) => n.id !== id);
|
|
||||||
if (currentNote.value?.id === id) {
|
if (currentNote.value?.id === id) {
|
||||||
currentNote.value = null;
|
currentNote.value = null;
|
||||||
}
|
}
|
||||||
@@ -120,50 +69,6 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTagFilter(tag: string) {
|
|
||||||
if (!activeTagFilters.value.includes(tag)) {
|
|
||||||
activeTagFilters.value.push(tag);
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTagFilter(tag: string) {
|
|
||||||
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearTagFilters() {
|
|
||||||
activeTagFilters.value = [];
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTagFilters(tags: string[]) {
|
|
||||||
activeTagFilters.value = [...tags];
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSort(field: string, order: "asc" | "desc") {
|
|
||||||
sortField.value = field;
|
|
||||||
sortOrder.value = order;
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setOffset(newOffset: number) {
|
|
||||||
offset.value = newOffset;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSearch(q: string) {
|
|
||||||
searchQuery.value = q;
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveTitle(title: string): Promise<Note> {
|
async function resolveTitle(title: string): Promise<Note> {
|
||||||
return await apiPost<Note>("/api/notes/resolve-title", { title });
|
return await apiPost<Note>("/api/notes/resolve-title", { title });
|
||||||
}
|
}
|
||||||
@@ -216,29 +121,12 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notes,
|
|
||||||
currentNote,
|
currentNote,
|
||||||
total,
|
|
||||||
loading,
|
loading,
|
||||||
activeTagFilters,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
sortField,
|
|
||||||
sortOrder,
|
|
||||||
searchQuery,
|
|
||||||
fetchNotes,
|
|
||||||
fetchNote,
|
fetchNote,
|
||||||
createNote,
|
createNote,
|
||||||
updateNote,
|
updateNote,
|
||||||
deleteNote,
|
deleteNote,
|
||||||
addTagFilter,
|
|
||||||
removeTagFilter,
|
|
||||||
clearTagFilters,
|
|
||||||
setTagFilters,
|
|
||||||
setSort,
|
|
||||||
setOffset,
|
|
||||||
setSearch,
|
|
||||||
refresh,
|
|
||||||
resolveTitle,
|
resolveTitle,
|
||||||
convertToTask,
|
convertToTask,
|
||||||
convertToNote,
|
convertToNote,
|
||||||
|
|||||||
@@ -54,13 +54,19 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
return rb;
|
return rb;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description">>) {
|
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description" | "always_on">>) {
|
||||||
const rb = await api.updateRulebook(id, data);
|
const rb = await api.updateRulebook(id, data);
|
||||||
const idx = rulebooks.value.findIndex((r) => r.id === id);
|
const idx = rulebooks.value.findIndex((r) => r.id === id);
|
||||||
if (idx >= 0) rulebooks.value[idx] = rb;
|
if (idx >= 0) rulebooks.value[idx] = rb;
|
||||||
return rb;
|
return rb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleAlwaysOn(id: number) {
|
||||||
|
const current = rulebooks.value.find((r) => r.id === id);
|
||||||
|
if (!current) return;
|
||||||
|
return updateRulebook(id, { always_on: !current.always_on });
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteRulebook(id: number) {
|
async function deleteRulebook(id: number) {
|
||||||
await api.deleteRulebook(id);
|
await api.deleteRulebook(id);
|
||||||
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
|
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
|
||||||
@@ -121,7 +127,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
return {
|
return {
|
||||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
|
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
|
||||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||||
createRulebook, updateRulebook, deleteRulebook,
|
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||||
createTopic, updateTopic, deleteTopic,
|
createTopic, updateTopic, deleteTopic,
|
||||||
createRule, updateRule, deleteRule,
|
createRule, updateRule, deleteRule,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,53 +2,15 @@ import { ref } from "vue";
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import type { Task, TaskListResponse, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
|
import type { Task, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
|
||||||
|
|
||||||
|
// Single-task + mutation surface. The list/filter/sort/pagination surface that
|
||||||
|
// backed the removed /tasks list view was dropped in the 2026-06-02 drift-audit
|
||||||
|
// cleanup (no consumers — ProjectView's kanban keeps its own local task list).
|
||||||
export const useTasksStore = defineStore("tasks", () => {
|
export const useTasksStore = defineStore("tasks", () => {
|
||||||
const tasks = ref<Task[]>([]);
|
|
||||||
const currentTask = ref<Task | null>(null);
|
const currentTask = ref<Task | null>(null);
|
||||||
const total = ref(0);
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
// Filter / pagination / sort state
|
|
||||||
const activeTagFilters = ref<string[]>([]);
|
|
||||||
const statusFilter = ref<TaskStatus[]>([]);
|
|
||||||
const priorityFilter = ref<TaskPriority[]>([]);
|
|
||||||
const limit = ref(20);
|
|
||||||
const offset = ref(0);
|
|
||||||
const sortField = ref("updated_at");
|
|
||||||
const sortOrder = ref<"asc" | "desc">("desc");
|
|
||||||
const searchQuery = ref("");
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
if (searchQuery.value) searchParams.set("q", searchQuery.value);
|
|
||||||
for (const t of activeTagFilters.value) {
|
|
||||||
searchParams.append("tag", t);
|
|
||||||
}
|
|
||||||
for (const s of statusFilter.value) searchParams.append("status", s);
|
|
||||||
for (const p of priorityFilter.value) searchParams.append("priority", p);
|
|
||||||
searchParams.set("sort", sortField.value);
|
|
||||||
searchParams.set("order", sortOrder.value);
|
|
||||||
searchParams.set("limit", String(limit.value));
|
|
||||||
searchParams.set("offset", String(offset.value));
|
|
||||||
|
|
||||||
const qs = searchParams.toString();
|
|
||||||
const data = await apiGet<TaskListResponse>(
|
|
||||||
`/api/tasks${qs ? `?${qs}` : ""}`
|
|
||||||
);
|
|
||||||
tasks.value = data.tasks;
|
|
||||||
total.value = data.total;
|
|
||||||
} catch (e) {
|
|
||||||
useToastStore().show("Failed to load tasks", "error");
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchTask(id: number) {
|
async function fetchTask(id: number) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -102,10 +64,6 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
async function patchStatus(id: number, status: TaskStatus): Promise<Task> {
|
async function patchStatus(id: number, status: TaskStatus): Promise<Task> {
|
||||||
try {
|
try {
|
||||||
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
|
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
|
||||||
const idx = tasks.value.findIndex((t) => t.id === id);
|
|
||||||
if (idx !== -1) {
|
|
||||||
tasks.value[idx] = task;
|
|
||||||
}
|
|
||||||
if (currentTask.value?.id === id) {
|
if (currentTask.value?.id === id) {
|
||||||
currentTask.value = task;
|
currentTask.value = task;
|
||||||
}
|
}
|
||||||
@@ -119,7 +77,6 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
async function deleteTask(id: number) {
|
async function deleteTask(id: number) {
|
||||||
try {
|
try {
|
||||||
await apiDelete(`/api/tasks/${id}`);
|
await apiDelete(`/api/tasks/${id}`);
|
||||||
tasks.value = tasks.value.filter((t) => t.id !== id);
|
|
||||||
if (currentTask.value?.id === id) {
|
if (currentTask.value?.id === id) {
|
||||||
currentTask.value = null;
|
currentTask.value = null;
|
||||||
}
|
}
|
||||||
@@ -144,83 +101,14 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatusFilter(statuses: TaskStatus[]) {
|
|
||||||
statusFilter.value = statuses;
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPriorityFilter(priorities: TaskPriority[]) {
|
|
||||||
priorityFilter.value = priorities;
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTagFilter(tag: string) {
|
|
||||||
if (!activeTagFilters.value.includes(tag)) {
|
|
||||||
activeTagFilters.value.push(tag);
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTagFilter(tag: string) {
|
|
||||||
activeTagFilters.value = activeTagFilters.value.filter((t) => t !== tag);
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearTagFilters() {
|
|
||||||
activeTagFilters.value = [];
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSort(field: string, order: "asc" | "desc") {
|
|
||||||
sortField.value = field;
|
|
||||||
sortOrder.value = order;
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setOffset(newOffset: number) {
|
|
||||||
offset.value = newOffset;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSearch(q: string) {
|
|
||||||
searchQuery.value = q;
|
|
||||||
offset.value = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tasks,
|
|
||||||
currentTask,
|
currentTask,
|
||||||
total,
|
|
||||||
loading,
|
loading,
|
||||||
activeTagFilters,
|
|
||||||
statusFilter,
|
|
||||||
priorityFilter,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
sortField,
|
|
||||||
sortOrder,
|
|
||||||
searchQuery,
|
|
||||||
refresh,
|
|
||||||
fetchTask,
|
fetchTask,
|
||||||
createTask,
|
createTask,
|
||||||
updateTask,
|
updateTask,
|
||||||
patchStatus,
|
patchStatus,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
startPlanning,
|
startPlanning,
|
||||||
setStatusFilter,
|
|
||||||
setPriorityFilter,
|
|
||||||
addTagFilter,
|
|
||||||
removeTagFilter,
|
|
||||||
clearTagFilters,
|
|
||||||
setSort,
|
|
||||||
setOffset,
|
|
||||||
setSearch,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
export interface GenerationTiming {
|
|
||||||
total_ms: number;
|
|
||||||
intent_ms: number | null;
|
|
||||||
ttft_ms: number | null;
|
|
||||||
generation_ms: number | null;
|
|
||||||
tools: Array<{ name: string; ms: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolCallRecord {
|
|
||||||
function: string;
|
|
||||||
arguments: Record<string, unknown>;
|
|
||||||
result: { success: boolean; type?: string; data?: Record<string, unknown>; error?: string; suggested_tags?: string[]; requires_confirmation?: boolean; similar_note?: { id: number; title: string } };
|
|
||||||
status: "running" | "success" | "error" | "declined";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolPendingRecord {
|
|
||||||
function: string;
|
|
||||||
arguments: Record<string, unknown>;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Message {
|
|
||||||
id: number;
|
|
||||||
conversation_id: number;
|
|
||||||
role: "system" | "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
status?: "complete" | "generating" | "error";
|
|
||||||
context_note_id: number | null;
|
|
||||||
context_note_title?: string | null;
|
|
||||||
tool_calls?: ToolCallRecord[] | null;
|
|
||||||
metadata?: Record<string, unknown> | null;
|
|
||||||
created_at: string;
|
|
||||||
timing?: GenerationTiming;
|
|
||||||
thinking?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendMessageResponse {
|
|
||||||
assistant_message_id: number;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Conversation {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
model: string;
|
|
||||||
message_count: number;
|
|
||||||
rag_project_id: number | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConversationDetail extends Conversation {
|
|
||||||
messages: Message[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContextMeta {
|
|
||||||
context_note_id: number | null;
|
|
||||||
context_note_title: string | null;
|
|
||||||
auto_notes: { id: number; title: string; score?: number | null; auto_injected?: boolean }[];
|
|
||||||
auto_injected_notes?: { id: number; title: string; score?: number | null }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OllamaStatus {
|
|
||||||
ollama: "available" | "unavailable";
|
|
||||||
model: "loaded" | "cold" | "not_found";
|
|
||||||
default_model: string;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||||
export type NoteType = "note" | "person" | "place" | "list";
|
export type NoteType = "note" | "person" | "place" | "list" | "process";
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
// Settings are free-form string key/value pairs keyed by setting name; the
|
||||||
|
// backend has no fixed schema, so this is an open string map. (The former
|
||||||
|
// assistant_name/default_model hints were dead after the Phase-8 chat removal.)
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
assistant_name?: string;
|
|
||||||
default_model?: string;
|
|
||||||
[key: string]: string | undefined;
|
[key: string]: string | undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,11 +168,11 @@ function onCalendarChanged() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener("mousedown", onDocClick);
|
document.addEventListener("mousedown", onDocClick);
|
||||||
document.addEventListener("fable:calendar-changed", onCalendarChanged);
|
document.addEventListener("scribe:calendar-changed", onCalendarChanged);
|
||||||
});
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener("mousedown", onDocClick);
|
document.removeEventListener("mousedown", onDocClick);
|
||||||
document.removeEventListener("fable:calendar-changed", onCalendarChanged);
|
document.removeEventListener("scribe:calendar-changed", onCalendarChanged);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Calendar callbacks ─────────────────────────────────────────────────────
|
// ── Calendar callbacks ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { apiGet } from "@/api/client";
|
||||||
|
import { relativeTime } from "@/composables/useRelativeTime";
|
||||||
|
|
||||||
|
interface TaskRow { id: number; title: string; status: string; priority: string }
|
||||||
|
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
|
||||||
|
interface ActiveProject {
|
||||||
|
id: number; title: string; color: string | null; last_activity: string;
|
||||||
|
open_count: number; progress_pct: number;
|
||||||
|
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
||||||
|
}
|
||||||
|
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
||||||
|
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
|
||||||
|
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
|
||||||
|
interface DashboardData {
|
||||||
|
active_projects: ActiveProject[];
|
||||||
|
recently_completed: DoneItem[];
|
||||||
|
upcoming_events: UpcomingEvent[];
|
||||||
|
week_stats: WeekStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = ref<DashboardData | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
data.value = await apiGet<DashboardData>("/api/dashboard");
|
||||||
|
} catch {
|
||||||
|
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function fmtEvent(e: UpcomingEvent): string {
|
||||||
|
if (!e.start_dt) return "";
|
||||||
|
const d = new Date(e.start_dt);
|
||||||
|
const day = d.toLocaleDateString(undefined, { weekday: "short" });
|
||||||
|
if (e.all_day) return day;
|
||||||
|
return `${day} ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dash-root">
|
||||||
|
<header class="dash-head">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<p class="dash-sub">What to work on, across your most-active projects.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading" class="dash-empty">Loading…</div>
|
||||||
|
|
||||||
|
<template v-else-if="data">
|
||||||
|
<!-- Done recently -->
|
||||||
|
<section v-if="data.recently_completed.length" class="done-strip">
|
||||||
|
<span class="dash-label">✓ Done recently</span>
|
||||||
|
<router-link
|
||||||
|
v-for="d in data.recently_completed"
|
||||||
|
:key="d.id"
|
||||||
|
:to="`/tasks/${d.id}`"
|
||||||
|
class="done-chip"
|
||||||
|
>
|
||||||
|
{{ d.title }}
|
||||||
|
<span class="done-meta">{{ d.project_title || "—" }} · {{ relativeTime(d.completed_at) }}</span>
|
||||||
|
</router-link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="dash-cols">
|
||||||
|
<!-- Active now -->
|
||||||
|
<main class="dash-main">
|
||||||
|
<div class="dash-label">Active now</div>
|
||||||
|
|
||||||
|
<div v-if="!data.active_projects.length" class="dash-empty card">
|
||||||
|
No active projects yet — <router-link to="/projects">create one</router-link>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article v-for="p in data.active_projects" :key="p.id" class="proj-panel">
|
||||||
|
<header class="proj-head">
|
||||||
|
<span class="proj-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
|
||||||
|
<router-link :to="`/projects/${p.id}`" class="proj-title">{{ p.title }}</router-link>
|
||||||
|
<span class="proj-meta">{{ relativeTime(p.last_activity) }} · {{ p.open_count }} open</span>
|
||||||
|
</header>
|
||||||
|
<div class="bar"><div class="bar-fill" :style="{ width: p.progress_pct + '%' }" /></div>
|
||||||
|
|
||||||
|
<div v-for="m in p.milestones" :key="m.id" class="ms-block">
|
||||||
|
<div class="ms-head">
|
||||||
|
<span class="ms-title">{{ m.title }}</span>
|
||||||
|
<span class="ms-pct">{{ m.progress_pct }}%</span>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-for="t in m.open_tasks"
|
||||||
|
:key="t.id"
|
||||||
|
:to="`/tasks/${t.id}`"
|
||||||
|
class="task-row"
|
||||||
|
:class="{ 'task-inprogress': t.status === 'in_progress' }"
|
||||||
|
>
|
||||||
|
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||||
|
<span class="task-title">{{ t.title }}</span>
|
||||||
|
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="p.no_milestone.length" class="ms-block">
|
||||||
|
<div class="ms-head"><span class="ms-title ms-none">No milestone</span></div>
|
||||||
|
<router-link
|
||||||
|
v-for="t in p.no_milestone"
|
||||||
|
:key="t.id"
|
||||||
|
:to="`/tasks/${t.id}`"
|
||||||
|
class="task-row"
|
||||||
|
:class="{ 'task-inprogress': t.status === 'in_progress' }"
|
||||||
|
>
|
||||||
|
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||||
|
<span class="task-title">{{ t.title }}</span>
|
||||||
|
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<router-link :to="`/projects/${p.id}`" class="proj-more">+ more in {{ p.title }} →</router-link>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Right rail -->
|
||||||
|
<aside class="dash-rail">
|
||||||
|
<div class="dash-label">Upcoming · 7 days</div>
|
||||||
|
<div class="rail-card">
|
||||||
|
<template v-if="data.upcoming_events.length">
|
||||||
|
<div v-for="e in data.upcoming_events" :key="e.id" class="evt-row">
|
||||||
|
<span class="evt-when">{{ fmtEvent(e) }}</span>
|
||||||
|
<span class="evt-title">{{ e.title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else class="rail-empty">Nothing scheduled.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dash-label">This week</div>
|
||||||
|
<div class="rail-card stats">
|
||||||
|
<span>✓ {{ data.week_stats.completed_this_week }} done</span>
|
||||||
|
<span>○ {{ data.week_stats.open_total }} open</span>
|
||||||
|
<span class="stats-sub">{{ data.week_stats.in_progress }} in progress · {{ data.week_stats.active_plans }} plans</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quick-add">
|
||||||
|
<router-link to="/tasks/new" class="qa-btn">+ Task</router-link>
|
||||||
|
<router-link to="/notes/new" class="qa-btn">+ Note</router-link>
|
||||||
|
<router-link to="/notes/new?type=process" class="qa-btn">+ Process</router-link>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
||||||
|
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
|
||||||
|
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
|
||||||
|
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
|
||||||
|
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
|
||||||
|
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
|
||||||
|
|
||||||
|
.done-strip { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1.5rem; }
|
||||||
|
.done-chip { display: inline-flex; flex-direction: column; gap: 1px; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 14px; padding: 4px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
||||||
|
.done-chip:hover { border-color: var(--color-primary); }
|
||||||
|
.done-meta { font-size: 0.7rem; color: var(--color-muted); }
|
||||||
|
|
||||||
|
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
|
||||||
|
.dash-main { flex: 1.7; min-width: 0; }
|
||||||
|
.dash-rail { flex: 1; min-width: 240px; }
|
||||||
|
|
||||||
|
.proj-panel { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
|
||||||
|
.proj-head { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.proj-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.proj-title { font-weight: 700; color: var(--color-text); text-decoration: none; }
|
||||||
|
.proj-title:hover { color: var(--color-primary); }
|
||||||
|
.proj-meta { margin-left: auto; font-size: 0.74rem; color: var(--color-muted); }
|
||||||
|
.bar { height: 5px; background: var(--color-border); border-radius: 3px; margin: 0.55rem 0 0.2rem; }
|
||||||
|
.bar-fill { height: 5px; background: var(--color-primary); border-radius: 3px; }
|
||||||
|
|
||||||
|
.ms-block { margin-top: 0.7rem; }
|
||||||
|
.ms-head { display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.3rem; }
|
||||||
|
.ms-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); }
|
||||||
|
.ms-title.ms-none { color: var(--color-muted); font-weight: 500; }
|
||||||
|
.ms-pct { margin-left: auto; font-size: 0.72rem; color: var(--color-muted); }
|
||||||
|
|
||||||
|
.task-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
|
||||||
|
.task-row:hover { background: var(--color-hover); }
|
||||||
|
.task-inprogress { border-left: 3px solid var(--color-primary); padding-left: calc(0.55rem - 3px); }
|
||||||
|
.task-mark { color: var(--color-muted); }
|
||||||
|
.task-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.task-pri { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 6px; border-radius: 8px; border: 1px solid var(--color-border); color: var(--color-muted); }
|
||||||
|
.pri-high { color: #c0556b; border-color: #c0556b66; }
|
||||||
|
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
|
||||||
|
|
||||||
|
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
|
||||||
|
.evt-row { display: flex; gap: 0.6rem; padding: 0.3rem 0; border-bottom: 1px solid var(--color-border); font-size: 0.85rem; }
|
||||||
|
.evt-row:last-child { border-bottom: none; }
|
||||||
|
.evt-when { color: var(--color-muted); white-space: nowrap; }
|
||||||
|
.evt-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
|
||||||
|
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
||||||
|
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
||||||
|
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
|
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
||||||
|
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
|
||||||
|
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
MapPin,
|
MapPin,
|
||||||
List,
|
List,
|
||||||
|
Workflow,
|
||||||
Search,
|
Search,
|
||||||
Share2,
|
Share2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -23,7 +24,7 @@ const router = useRouter();
|
|||||||
|
|
||||||
interface KnowledgeItem {
|
interface KnowledgeItem {
|
||||||
id: number;
|
id: number;
|
||||||
note_type: "note" | "person" | "place" | "list" | "task";
|
note_type: "note" | "person" | "place" | "list" | "task" | "process";
|
||||||
title: string;
|
title: string;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -61,7 +62,7 @@ interface UpcomingEvent {
|
|||||||
|
|
||||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan">("");
|
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan" | "process">("");
|
||||||
const activeTag = ref("");
|
const activeTag = ref("");
|
||||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
@@ -69,8 +70,8 @@ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|||||||
|
|
||||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; total: number }
|
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; process: number; total: number }
|
||||||
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, total: 0 });
|
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, process: 0, total: 0 });
|
||||||
|
|
||||||
async function fetchCounts() {
|
async function fetchCounts() {
|
||||||
try {
|
try {
|
||||||
@@ -403,6 +404,10 @@ onUnmounted(() => {
|
|||||||
<List :size="16" />
|
<List :size="16" />
|
||||||
List
|
List
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="createNew('process')">
|
||||||
|
<Workflow :size="16" />
|
||||||
|
Process
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -417,11 +422,11 @@ onUnmounted(() => {
|
|||||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
|
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list'],['process','Processes','process']] as [string,string,string][])"
|
||||||
:key="val"
|
:key="val"
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
:class="{ active: activeType === val }"
|
:class="{ active: activeType === val }"
|
||||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan')"
|
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan' | 'process')"
|
||||||
>
|
>
|
||||||
<span class="filter-btn-label">{{ label }}</span>
|
<span class="filter-btn-label">{{ label }}</span>
|
||||||
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||||
@@ -494,6 +499,7 @@ onUnmounted(() => {
|
|||||||
<span v-else-if="item.note_type === 'person'">Person</span>
|
<span v-else-if="item.note_type === 'person'">Person</span>
|
||||||
<span v-else-if="item.note_type === 'place'">Place</span>
|
<span v-else-if="item.note_type === 'place'">Place</span>
|
||||||
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
||||||
|
<span v-else-if="item.note_type === 'process'">Process</span>
|
||||||
<span v-else>List</span>
|
<span v-else>List</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ const titlePlaceholder = computed(() => {
|
|||||||
case 'person': return 'Name';
|
case 'person': return 'Name';
|
||||||
case 'place': return 'Place name';
|
case 'place': return 'Place name';
|
||||||
case 'list': return 'List title';
|
case 'list': return 'List title';
|
||||||
|
case 'process': return 'Process name';
|
||||||
default: return 'Title';
|
default: return 'Title';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -602,6 +603,18 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ── Process (prompt) editor ──────────────────────────── -->
|
||||||
|
<template v-else-if="noteType === 'process'">
|
||||||
|
<label class="ef-label">Prompt</label>
|
||||||
|
<textarea
|
||||||
|
class="prompt-editor"
|
||||||
|
:value="body"
|
||||||
|
@input="onBodyUpdate(($event.target as HTMLTextAreaElement).value)"
|
||||||
|
placeholder="The prompt to run when this process is fired (markdown). If it needs inputs, instruct Claude to ask up front."
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- ── Generic note editor ──────────────────────────────── -->
|
<!-- ── Generic note editor ──────────────────────────────── -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="body-tabs-row">
|
<div class="body-tabs-row">
|
||||||
@@ -1038,6 +1051,26 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
.prompt-editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 60vh;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
/* Prompts are plain markdown — a code-style editor, not rich text. */
|
||||||
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
tab-size: 2;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.prompt-editor:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
.ef-input {
|
.ef-input {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ interface Project {
|
|||||||
goal: string | null;
|
goal: string | null;
|
||||||
status: "active" | "paused" | "completed" | "archived";
|
status: "active" | "paused" | "completed" | "archived";
|
||||||
color: string | null;
|
color: string | null;
|
||||||
auto_summary: string | null;
|
|
||||||
permission?: string;
|
permission?: string;
|
||||||
is_shared?: boolean;
|
is_shared?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ interface Project {
|
|||||||
goal: string | null;
|
goal: string | null;
|
||||||
status: "active" | "paused" | "completed" | "archived";
|
status: "active" | "paused" | "completed" | "archived";
|
||||||
color: string | null;
|
color: string | null;
|
||||||
auto_summary: string | null;
|
|
||||||
permission?: string;
|
permission?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -14,25 +14,10 @@ const toastStore = useToastStore();
|
|||||||
const userTimezone = ref("");
|
const userTimezone = ref("");
|
||||||
const savingTimezone = ref(false);
|
const savingTimezone = ref(false);
|
||||||
const timezoneSaved = ref(false);
|
const timezoneSaved = ref(false);
|
||||||
const autoConsolidateTasks = ref(true);
|
|
||||||
const savingAutoConsolidate = ref(false);
|
|
||||||
const trashRetentionDays = ref("90");
|
const trashRetentionDays = ref("90");
|
||||||
const savingRetention = ref(false);
|
const savingRetention = ref(false);
|
||||||
const retentionSaved = ref(false);
|
const retentionSaved = ref(false);
|
||||||
|
|
||||||
async function saveAutoConsolidate() {
|
|
||||||
savingAutoConsolidate.value = true;
|
|
||||||
try {
|
|
||||||
await apiPut('/api/settings', {
|
|
||||||
auto_consolidate_tasks: autoConsolidateTasks.value ? "true" : "false",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
toastStore.show('Failed to save setting', 'error');
|
|
||||||
} finally {
|
|
||||||
savingAutoConsolidate.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// think_enabled setting removed 2026-05-23. The chat+curator architecture
|
// think_enabled setting removed 2026-05-23. The chat+curator architecture
|
||||||
// has tools=[] on the chat model; think on a no-tools conversational pass
|
// has tools=[] on the chat model; think on a no-tools conversational pass
|
||||||
// is pure latency cost. See generation_task.py:run_generation comment for
|
// is pure latency cost. See generation_task.py:run_generation comment for
|
||||||
@@ -403,8 +388,6 @@ onMounted(async () => {
|
|||||||
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
||||||
userTimezone.value = allSettings.user_timezone ?? "";
|
userTimezone.value = allSettings.user_timezone ?? "";
|
||||||
trashRetentionDays.value = allSettings.trash_retention_days ?? "90";
|
trashRetentionDays.value = allSettings.trash_retention_days ?? "90";
|
||||||
// Default true if unset; explicit "false" disables auto-consolidation.
|
|
||||||
autoConsolidateTasks.value = (allSettings.auto_consolidate_tasks ?? "true") !== "false";
|
|
||||||
if (allSettings.notify_task_reminders !== undefined) {
|
if (allSettings.notify_task_reminders !== undefined) {
|
||||||
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
|
||||||
}
|
}
|
||||||
@@ -527,7 +510,7 @@ async function exportData(scope: "user" | "full") {
|
|||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = `fabledassistant-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
|
a.download = `scribe-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(a.href);
|
URL.revokeObjectURL(a.href);
|
||||||
toastStore.show("Backup downloaded");
|
toastStore.show("Backup downloaded");
|
||||||
@@ -550,7 +533,7 @@ async function exportNotes(format: "markdown" | "json") {
|
|||||||
const stamp = new Date().toISOString().slice(0, 10);
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = `fabledassistant-${stamp}.${ext}`;
|
a.download = `scribe-${stamp}.${ext}`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(a.href);
|
URL.revokeObjectURL(a.href);
|
||||||
toastStore.show("Export downloaded");
|
toastStore.show("Export downloaded");
|
||||||
@@ -992,29 +975,6 @@ function formatUserDate(iso: string): string {
|
|||||||
|
|
||||||
<!-- ── General ── -->
|
<!-- ── General ── -->
|
||||||
<div v-show="activeTab === 'general'" class="settings-grid">
|
<div v-show="activeTab === 'general'" class="settings-grid">
|
||||||
<!-- Tasks -->
|
|
||||||
<section class="settings-section full-width">
|
|
||||||
<h2>Tasks</h2>
|
|
||||||
<p class="section-desc">
|
|
||||||
Task bodies are auto-summarized from accumulated work logs. The summary runs every few logs, plus on task close.
|
|
||||||
</p>
|
|
||||||
<div class="field">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
v-model="autoConsolidateTasks"
|
|
||||||
:disabled="savingAutoConsolidate"
|
|
||||||
@change="saveAutoConsolidate"
|
|
||||||
/>
|
|
||||||
Auto-consolidate task bodies
|
|
||||||
</label>
|
|
||||||
<p class="field-hint">
|
|
||||||
When off, the task body is only refreshed when you click "Re-consolidate"
|
|
||||||
on a task. Existing summaries remain in place.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Timezone -->
|
<!-- Timezone -->
|
||||||
<section class="settings-section full-width">
|
<section class="settings-section full-width">
|
||||||
<h2>Timezone</h2>
|
<h2>Timezone</h2>
|
||||||
@@ -3116,7 +3076,7 @@ function formatUserDate(iso: string): string {
|
|||||||
.settings-empty { opacity: 0.5; margin-top: 1rem; }
|
.settings-empty { opacity: 0.5; margin-top: 1rem; }
|
||||||
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
|
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
|
||||||
|
|
||||||
/* Fable MCP section */
|
/* Scribe MCP section */
|
||||||
.mcp-status { opacity: 0.6; font-size: 0.9rem; }
|
.mcp-status { opacity: 0.6; font-size: 0.9rem; }
|
||||||
.mcp-unavailable p { opacity: 0.7; }
|
.mcp-unavailable p { opacity: 0.7; }
|
||||||
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
|
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ requires = ["setuptools>=68.0", "setuptools-scm>=8.0"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "fabledassistant"
|
name = "scribe"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Self-hosted note-taking, project-management, and calendar app exposed to Claude via MCP"
|
description = "Self-hosted note-taking, project-management, and calendar app exposed to Claude via MCP"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ async def run(args) -> None:
|
|||||||
print(f" - {r['title']}: {preview}{empty_flag}")
|
print(f" - {r['title']}: {preview}{empty_flag}")
|
||||||
return
|
return
|
||||||
|
|
||||||
from fabledassistant.services import rulebooks as rb_service
|
from scribe.services import rulebooks as rb_service
|
||||||
|
|
||||||
existing = await rb_service.find_rulebook_by_title(args.user_id, args.rulebook_title)
|
existing = await rb_service.find_rulebook_by_title(args.user_id, args.rulebook_title)
|
||||||
if existing is not None and not args.force:
|
if existing is not None and not args.force:
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
"""MCP-side Bearer token resolution. Reuses the existing api_keys infrastructure."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fabledassistant.services.api_keys import lookup_key
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
|
|
||||||
"""Parse an `Authorization: Bearer <token>` header and return the user_id.
|
|
||||||
|
|
||||||
Returns None if the header is missing, malformed, or the token is invalid
|
|
||||||
or revoked. The underlying lookup_key already updates last_used_at on hit.
|
|
||||||
"""
|
|
||||||
if not auth_header or not auth_header.startswith("Bearer "):
|
|
||||||
return None
|
|
||||||
raw_token = auth_header[len("Bearer "):].strip()
|
|
||||||
if not raw_token:
|
|
||||||
return None
|
|
||||||
api_key = await lookup_key(raw_token)
|
|
||||||
return api_key.user_id if api_key else None
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
"""FastMCP instance + Quart mount-point. Tools are registered in mcp/tools/."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
|
||||||
from mcp.server.transport_security import TransportSecuritySettings
|
|
||||||
from quart import Quart
|
|
||||||
|
|
||||||
_INSTRUCTIONS = """
|
|
||||||
Scribe is the user's self-hosted second-brain and project-management data
|
|
||||||
store. You (Claude) are the assistant.
|
|
||||||
|
|
||||||
Hierarchy: Project -> Milestone -> Task/Note.
|
|
||||||
|
|
||||||
What each part is for, and when to reach for it:
|
|
||||||
- Project: the top-level container for a body of work.
|
|
||||||
- Milestone: groups related tasks within a project toward a goal (status
|
|
||||||
active/done). Use one when a chunk of work needs its own arc.
|
|
||||||
- Task: a unit of actionable work with a lifecycle (status
|
|
||||||
todo/in_progress/done/cancelled, optional priority). A task is a note with a
|
|
||||||
status — reach for one when there is something to DO. Record progress over
|
|
||||||
time with work-logs (add_task_log) rather than rewriting the body.
|
|
||||||
- Plan: a task with kind=plan — HOW you'll execute a chunk of work. The body
|
|
||||||
holds the design + step checklist; work-logs record progress. Start one with
|
|
||||||
start_planning when beginning non-trivial work, before writing code.
|
|
||||||
- Note: durable free-form knowledge — reference material, decisions, dev-logs.
|
|
||||||
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
|
|
||||||
- Typed entities (person/place/list): structured records about people, places,
|
|
||||||
and checklists.
|
|
||||||
|
|
||||||
Mechanics:
|
|
||||||
- Notes and Tasks share a model; tasks are notes with is_task=True.
|
|
||||||
- Use the *_note tools for notes, the *_task tools for tasks. Don't mix them.
|
|
||||||
- Typed entities (person, place, list) are notes with a non-default note_type
|
|
||||||
plus type-specific columns; use the dedicated *_person / *_place / *_list
|
|
||||||
tools rather than create_note.
|
|
||||||
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
|
|
||||||
unchanged on updates.
|
|
||||||
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
|
|
||||||
"not set".
|
|
||||||
|
|
||||||
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
|
|
||||||
an actionable statement plus optional Why and How-to-apply context. When you
|
|
||||||
start work on a project, get_project(id) returns applicable_rules (the rules
|
|
||||||
from rulebooks the project subscribes to) and subscribed_rulebooks. Consult
|
|
||||||
these before making decisions about workflow, conventions, or scope. Full
|
|
||||||
text (Why / How-to-apply) is available via get_rule(id). You may create new
|
|
||||||
rules via create_rule when you notice a pattern worth codifying — coordinate
|
|
||||||
with the operator on whether it belongs in an existing rulebook+topic or a
|
|
||||||
new one.
|
|
||||||
|
|
||||||
Plans are tasks with kind=plan. Begin a plan with start_planning(project_id,
|
|
||||||
title) — that seeds a plan template and returns the project's applicable_rules.
|
|
||||||
Maintain the plan with the normal task tools (update_task for the body,
|
|
||||||
add_task_log for progress); its work-logs are the build record. Build plans in
|
|
||||||
Scribe via start_planning, not in local .md files.
|
|
||||||
|
|
||||||
Deletes are recoverable: every delete_* tool moves the entity (and its
|
|
||||||
descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
|
|
||||||
see trashed batches, restore(deleted_batch_id) to undo a deletion, and
|
|
||||||
purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash
|
|
||||||
auto-purges after the operator's retention window.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def build_mcp_server() -> FastMCP:
|
|
||||||
"""Build the FastMCP instance with all tools registered.
|
|
||||||
|
|
||||||
DNS-rebinding protection is disabled: FastMCP's default allow-list is
|
|
||||||
just localhost variants, which means any deployment behind a reverse
|
|
||||||
proxy (Traefik with a hostname like devassistant.traefik.internal,
|
|
||||||
Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat
|
|
||||||
model that protection addresses — a malicious browser page rebinding
|
|
||||||
DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport
|
|
||||||
behind a reverse proxy with bearer-token auth as the real security
|
|
||||||
boundary.
|
|
||||||
"""
|
|
||||||
# stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
|
|
||||||
# The stateful default strands Claude Code after a container redeploy —
|
|
||||||
# it reconnects with the now-unknown session id, the server returns 404,
|
|
||||||
# and the client won't re-initialize on a 404 (Claude Code issue #60949),
|
|
||||||
# so the connection stays dead until a manual /mcp retry. Stateless makes
|
|
||||||
# every request self-contained (bearer-auth only), so a post-deploy
|
|
||||||
# reconnect just works. Trade-off: no server-pushed list_changed stream,
|
|
||||||
# which we don't use — tools are re-fetched on reconnect anyway.
|
|
||||||
mcp = FastMCP(
|
|
||||||
"scribe",
|
|
||||||
instructions=_INSTRUCTIONS.strip(),
|
|
||||||
stateless_http=True,
|
|
||||||
transport_security=TransportSecuritySettings(
|
|
||||||
enable_dns_rebinding_protection=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
from fabledassistant.mcp.tools import register_all
|
|
||||||
register_all(mcp)
|
|
||||||
return mcp
|
|
||||||
|
|
||||||
|
|
||||||
def mount_mcp(app: Quart) -> None:
|
|
||||||
"""Mount the FastMCP streamable-HTTP ASGI sub-app at /mcp on the Quart app.
|
|
||||||
|
|
||||||
A small ASGI middleware between Quart and the FastMCP sub-app validates the
|
|
||||||
Bearer token against the api_keys table. Authenticated requests have their
|
|
||||||
user_id attached to the ASGI scope under "scribe_user_id" for tool handlers
|
|
||||||
to read.
|
|
||||||
|
|
||||||
FastMCP's streamable_http session manager owns a task group that must be
|
|
||||||
running before it can serve requests. In a stand-alone Starlette deployment
|
|
||||||
that would happen via the Starlette `lifespan` parameter. Since we're hosted
|
|
||||||
inside Quart, we hook the session manager's `run()` async context manager
|
|
||||||
into Quart's serving lifecycle (before_serving / after_serving).
|
|
||||||
"""
|
|
||||||
from fabledassistant.mcp.auth import resolve_bearer_to_user_id
|
|
||||||
|
|
||||||
mcp = build_mcp_server()
|
|
||||||
mcp_asgi = mcp.streamable_http_app()
|
|
||||||
app.mcp_instance = mcp
|
|
||||||
|
|
||||||
@app.before_serving
|
|
||||||
async def _start_mcp_session() -> None:
|
|
||||||
cm = mcp.session_manager.run()
|
|
||||||
await cm.__aenter__()
|
|
||||||
app._mcp_session_cm = cm
|
|
||||||
|
|
||||||
@app.after_serving
|
|
||||||
async def _stop_mcp_session() -> None:
|
|
||||||
cm = getattr(app, "_mcp_session_cm", None)
|
|
||||||
if cm is not None:
|
|
||||||
await cm.__aexit__(None, None, None)
|
|
||||||
|
|
||||||
async def auth_wrapped(scope, receive, send):
|
|
||||||
if scope["type"] != "http":
|
|
||||||
return await mcp_asgi(scope, receive, send)
|
|
||||||
# ASGI headers are lowercase bytes per spec; lowercase explicitly to be safe.
|
|
||||||
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
|
|
||||||
user_id = await resolve_bearer_to_user_id(headers.get("authorization"))
|
|
||||||
if user_id is None:
|
|
||||||
await send({
|
|
||||||
"type": "http.response.start",
|
|
||||||
"status": 401,
|
|
||||||
"headers": [
|
|
||||||
(b"content-type", b"application/json"),
|
|
||||||
(b"www-authenticate", b'Bearer realm="scribe-mcp"'),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
await send({
|
|
||||||
"type": "http.response.body",
|
|
||||||
"body": b'{"error":"unauthorized"}',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
scope["scribe_user_id"] = user_id
|
|
||||||
from fabledassistant.mcp._context import _user_id_ctx
|
|
||||||
token = _user_id_ctx.set(user_id)
|
|
||||||
try:
|
|
||||||
await mcp_asgi(scope, receive, send)
|
|
||||||
finally:
|
|
||||||
_user_id_ctx.reset(token)
|
|
||||||
|
|
||||||
original_asgi = app.asgi_app
|
|
||||||
|
|
||||||
async def dispatch(scope, receive, send):
|
|
||||||
if scope["type"] == "http":
|
|
||||||
path = scope.get("path", "")
|
|
||||||
if path == "/mcp" or path.startswith("/mcp/"):
|
|
||||||
# Don't rewrite the path: FastMCP's streamable_http_app mounts
|
|
||||||
# its handler at /mcp by default. If we strip the prefix to "/",
|
|
||||||
# FastMCP's internal routing returns 404 because there's no
|
|
||||||
# handler at "/" — only at "/mcp". Pass the scope through
|
|
||||||
# untouched and let FastMCP's own routing match.
|
|
||||||
return await auth_wrapped(scope, receive, send)
|
|
||||||
return await original_asgi(scope, receive, send)
|
|
||||||
|
|
||||||
app.asgi_app = dispatch
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
|
||||||
|
|
||||||
from fabledassistant.config import Config
|
|
||||||
|
|
||||||
engine = create_async_engine(
|
|
||||||
Config.DATABASE_URL,
|
|
||||||
echo=False,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
pool_recycle=1800,
|
|
||||||
)
|
|
||||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin # noqa: E402, F401
|
|
||||||
|
|
||||||
|
|
||||||
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
|
|
||||||
from fabledassistant.models.setting import Setting # noqa: E402, F401
|
|
||||||
from fabledassistant.models.user import User # noqa: E402, F401
|
|
||||||
from fabledassistant.models.app_log import AppLog # noqa: E402, F401
|
|
||||||
from fabledassistant.models.password_reset import PasswordResetToken # noqa: E402, F401
|
|
||||||
from fabledassistant.models.invitation import InvitationToken # noqa: E402, F401
|
|
||||||
from fabledassistant.models.embedding import NoteEmbedding # noqa: E402, F401
|
|
||||||
from fabledassistant.models.project import Project # noqa: E402, F401
|
|
||||||
from fabledassistant.models.event import Event # noqa: E402, F401
|
|
||||||
from fabledassistant.models.milestone import Milestone # noqa: E402, F401
|
|
||||||
from fabledassistant.models.task_log import TaskLog # noqa: E402, F401
|
|
||||||
from fabledassistant.models.note_draft import NoteDraft # noqa: E402, F401
|
|
||||||
from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401
|
|
||||||
from fabledassistant.models.group import Group, GroupMembership # noqa: E402, F401
|
|
||||||
from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, F401
|
|
||||||
from fabledassistant.models.notification import Notification # noqa: E402, F401
|
|
||||||
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
|
|
||||||
from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401
|
|
||||||
from fabledassistant.models.rulebook import ( # noqa: E402, F401
|
|
||||||
Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
|
|
||||||
)
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
"""Calendar sync service: bridges the DB Event table with local Radicale.
|
|
||||||
|
|
||||||
Each user's calendars live at: http://radicale:5232/user_{user_id}/calendar/
|
|
||||||
The app connects without auth (Radicale runs with auth.type = none inside Docker).
|
|
||||||
|
|
||||||
External clients (iOS, macOS, Thunderbird) connect directly to Radicale on port 5232.
|
|
||||||
Their CalDAV URL is: http://<host>:5232/user_{user_id}/calendar/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import icalendar
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from fabledassistant.config import Config
|
|
||||||
from fabledassistant.models import async_session
|
|
||||||
from fabledassistant.models.event import Event
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Internal Radicale URL (Docker service name)
|
|
||||||
_RADICALE_INTERNAL = "http://radicale:5232"
|
|
||||||
|
|
||||||
|
|
||||||
def _user_calendar_path(user_id: int) -> str:
|
|
||||||
return f"/user_{user_id}/calendar/"
|
|
||||||
|
|
||||||
|
|
||||||
def _user_calendar_url(user_id: int) -> str:
|
|
||||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
|
||||||
return f"{base}/user_{user_id}/calendar/"
|
|
||||||
|
|
||||||
|
|
||||||
async def setup_user_calendar(user_id: int) -> bool:
|
|
||||||
"""Create Radicale collection for user if it doesn't exist.
|
|
||||||
|
|
||||||
Returns True on success or if already exists, False on error.
|
|
||||||
"""
|
|
||||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
|
||||||
user_url = f"{base}/user_{user_id}/"
|
|
||||||
cal_url = f"{base}/user_{user_id}/calendar/"
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
# Create user home collection
|
|
||||||
await client.request(
|
|
||||||
"MKCOL",
|
|
||||||
user_url,
|
|
||||||
headers={"Content-Type": "text/xml"},
|
|
||||||
content="""<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<set><prop><resourcetype><collection/></resourcetype></prop></set>
|
|
||||||
</mkcol>""",
|
|
||||||
)
|
|
||||||
# Create calendar collection
|
|
||||||
resp = await client.request(
|
|
||||||
"MKCOL",
|
|
||||||
cal_url,
|
|
||||||
headers={"Content-Type": "text/xml"},
|
|
||||||
content="""<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<set>
|
|
||||||
<prop>
|
|
||||||
<resourcetype><collection/><C:calendar/></resourcetype>
|
|
||||||
<displayname>Fabled Scribe</displayname>
|
|
||||||
</prop>
|
|
||||||
</set>
|
|
||||||
</mkcol>""",
|
|
||||||
)
|
|
||||||
if resp.status_code in (201, 405): # 405 = already exists
|
|
||||||
logger.info("Calendar collection ready for user %d", user_id)
|
|
||||||
return True
|
|
||||||
logger.warning("Unexpected status %d creating calendar for user %d", resp.status_code, user_id)
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to setup Radicale calendar for user %d", user_id, exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _make_vcalendar(event: Event) -> str:
|
|
||||||
"""Build iCalendar string from DB Event."""
|
|
||||||
cal = icalendar.Calendar()
|
|
||||||
cal.add("prodid", "-//FabledAssistant//EN")
|
|
||||||
cal.add("version", "2.0")
|
|
||||||
|
|
||||||
vevent = icalendar.Event()
|
|
||||||
vevent.add("uid", event.uid)
|
|
||||||
vevent.add("summary", event.title)
|
|
||||||
|
|
||||||
if event.all_day:
|
|
||||||
vevent.add("dtstart", event.start_dt.date())
|
|
||||||
if event.end_dt:
|
|
||||||
vevent.add("dtend", event.end_dt.date())
|
|
||||||
else:
|
|
||||||
vevent.add("dtstart", event.start_dt)
|
|
||||||
if event.end_dt:
|
|
||||||
vevent.add("dtend", event.end_dt)
|
|
||||||
|
|
||||||
if event.description:
|
|
||||||
vevent.add("description", event.description)
|
|
||||||
if event.location:
|
|
||||||
vevent.add("location", event.location)
|
|
||||||
if event.recurrence:
|
|
||||||
rrule_parts: dict = {}
|
|
||||||
for part in event.recurrence.split(";"):
|
|
||||||
if "=" in part:
|
|
||||||
key, value = part.split("=", 1)
|
|
||||||
rrule_parts[key.strip().lower()] = value.strip()
|
|
||||||
if rrule_parts:
|
|
||||||
vevent.add("rrule", rrule_parts)
|
|
||||||
|
|
||||||
vevent.add("dtstamp", datetime.now(timezone.utc))
|
|
||||||
cal.add_component(vevent)
|
|
||||||
return cal.to_ical().decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
async def push_event_to_radicale(user_id: int, event: Event) -> bool:
|
|
||||||
"""Write a DB Event to Radicale. Creates or updates the resource.
|
|
||||||
|
|
||||||
Returns True on success.
|
|
||||||
"""
|
|
||||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
|
||||||
resource_url = f"{base}/user_{user_id}/calendar/{event.uid}.ics"
|
|
||||||
ical_data = _make_vcalendar(event)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
resp = await client.put(
|
|
||||||
resource_url,
|
|
||||||
content=ical_data.encode("utf-8"),
|
|
||||||
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
|
||||||
)
|
|
||||||
if resp.status_code in (200, 201, 204):
|
|
||||||
return True
|
|
||||||
logger.warning("Radicale PUT returned %d for event %s", resp.status_code, event.uid)
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to push event %s to Radicale", event.uid, exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_event_from_radicale(user_id: int, uid: str) -> bool:
|
|
||||||
"""Delete an event from Radicale by UID."""
|
|
||||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
|
||||||
resource_url = f"{base}/user_{user_id}/calendar/{uid}.ics"
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
resp = await client.delete(resource_url)
|
|
||||||
return resp.status_code in (200, 204, 404) # 404 is ok (already gone)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to delete event %s from Radicale", uid, exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
|
|
||||||
"""Fetch an event from Radicale and upsert into the DB Event table.
|
|
||||||
|
|
||||||
Returns the upserted Event or None on error.
|
|
||||||
"""
|
|
||||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
|
||||||
resource_url = f"{base}/user_{user_id}/calendar/{ical_uid}.ics"
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
resp = await client.get(resource_url)
|
|
||||||
if resp.status_code == 404:
|
|
||||||
return None
|
|
||||||
resp.raise_for_status()
|
|
||||||
ical_data = resp.text
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to fetch event %s from Radicale", ical_uid, exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
cal = icalendar.Calendar.from_ical(ical_data)
|
|
||||||
for component in cal.walk():
|
|
||||||
if component.name != "VEVENT":
|
|
||||||
continue
|
|
||||||
title = str(component.get("SUMMARY", ""))
|
|
||||||
dtstart = component.get("DTSTART")
|
|
||||||
dtend = component.get("DTEND")
|
|
||||||
description = str(component.get("DESCRIPTION", ""))
|
|
||||||
location = str(component.get("LOCATION", ""))
|
|
||||||
rrule = component.get("RRULE")
|
|
||||||
recurrence = rrule.to_ical().decode("utf-8") if rrule else None
|
|
||||||
|
|
||||||
all_day = False
|
|
||||||
if dtstart and isinstance(dtstart.dt, datetime):
|
|
||||||
start_dt = dtstart.dt if dtstart.dt.tzinfo else dtstart.dt.replace(tzinfo=timezone.utc)
|
|
||||||
elif dtstart:
|
|
||||||
from datetime import date
|
|
||||||
d = dtstart.dt
|
|
||||||
start_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
|
||||||
all_day = True
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
end_dt = None
|
|
||||||
if dtend and isinstance(dtend.dt, datetime):
|
|
||||||
end_dt = dtend.dt if dtend.dt.tzinfo else dtend.dt.replace(tzinfo=timezone.utc)
|
|
||||||
elif dtend:
|
|
||||||
from datetime import date
|
|
||||||
d = dtend.dt
|
|
||||||
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
# Storage uses duration, not end_dt. Convert iCal DTEND to a
|
|
||||||
# minute count anchored on DTSTART. Treat invalid (end <= start)
|
|
||||||
# incoming data as a point event rather than rejecting; we
|
|
||||||
# don't control external CalDAV writers.
|
|
||||||
duration_minutes = None
|
|
||||||
if end_dt is not None and end_dt > start_dt:
|
|
||||||
duration_minutes = int((end_dt - start_dt).total_seconds() // 60)
|
|
||||||
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
|
|
||||||
)
|
|
||||||
existing = result.scalars().first()
|
|
||||||
if existing:
|
|
||||||
existing.title = title
|
|
||||||
existing.start_dt = start_dt
|
|
||||||
existing.duration_minutes = duration_minutes
|
|
||||||
existing.all_day = all_day
|
|
||||||
existing.description = description
|
|
||||||
existing.location = location
|
|
||||||
existing.recurrence = recurrence
|
|
||||||
existing.updated_at = datetime.now(timezone.utc)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(existing)
|
|
||||||
return existing
|
|
||||||
else:
|
|
||||||
event_obj = Event(
|
|
||||||
user_id=user_id,
|
|
||||||
uid=ical_uid,
|
|
||||||
title=title,
|
|
||||||
start_dt=start_dt,
|
|
||||||
duration_minutes=duration_minutes,
|
|
||||||
all_day=all_day,
|
|
||||||
description=description,
|
|
||||||
location=location,
|
|
||||||
recurrence=recurrence,
|
|
||||||
)
|
|
||||||
session.add(event_obj)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(event_obj)
|
|
||||||
return event_obj
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to parse/upsert Radicale event %s", ical_uid, exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_all_events_from_radicale(user_id: int) -> int:
|
|
||||||
"""Full sync: fetch all events from Radicale calendar and upsert into DB.
|
|
||||||
|
|
||||||
Returns number of events synced.
|
|
||||||
"""
|
|
||||||
base = Config.RADICALE_URL.rstrip("/") if Config.RADICALE_URL else _RADICALE_INTERNAL
|
|
||||||
cal_url = f"{base}/user_{user_id}/calendar/"
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
# PROPFIND to get all .ics resources
|
|
||||||
resp = await client.request(
|
|
||||||
"PROPFIND",
|
|
||||||
cal_url,
|
|
||||||
headers={"Depth": "1", "Content-Type": "text/xml"},
|
|
||||||
content="""<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<propfind xmlns="DAV:"><prop><getetag/><getcontenttype/></prop></propfind>""",
|
|
||||||
)
|
|
||||||
if resp.status_code == 404:
|
|
||||||
logger.info("No calendar found for user %d — setting up", user_id)
|
|
||||||
await setup_user_calendar(user_id)
|
|
||||||
return 0
|
|
||||||
if resp.status_code != 207:
|
|
||||||
logger.warning("PROPFIND returned %d for user %d", resp.status_code, user_id)
|
|
||||||
return 0
|
|
||||||
# Extract UIDs from href paths
|
|
||||||
import re
|
|
||||||
hrefs = re.findall(r"<[Dd]:[Hh]ref>([^<]+\.ics)</[Dd]:[Hh]ref>", resp.text)
|
|
||||||
uids = [h.rstrip("/").split("/")[-1].removesuffix(".ics") for h in hrefs]
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to list Radicale events for user %d", user_id, exc_info=True)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
synced = 0
|
|
||||||
for uid in uids:
|
|
||||||
if uid:
|
|
||||||
event = await sync_event_to_db(user_id, uid)
|
|
||||||
if event:
|
|
||||||
synced += 1
|
|
||||||
|
|
||||||
logger.info("Synced %d events from Radicale for user %d", synced, user_id)
|
|
||||||
return synced
|
|
||||||
|
|
||||||
|
|
||||||
def get_external_caldav_url(user_id: int) -> str:
|
|
||||||
"""Return the CalDAV URL for external clients (iOS, macOS, Thunderbird).
|
|
||||||
|
|
||||||
Uses BASE_URL from Config with port 5232 substituted, or RADICALE_EXTERNAL_URL.
|
|
||||||
"""
|
|
||||||
external = Config.RADICALE_EXTERNAL_URL
|
|
||||||
if external:
|
|
||||||
return f"{external.rstrip('/')}/user_{user_id}/calendar/"
|
|
||||||
# Fall back to BASE_URL host with port 5232
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
parsed = urlparse(Config.BASE_URL)
|
|
||||||
host = parsed.hostname or "localhost"
|
|
||||||
return f"http://{host}:5232/user_{user_id}/calendar/"
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
"""Scheduler jobs for background maintenance tasks.
|
|
||||||
|
|
||||||
- Reminder notifications: checks every 5 minutes for due event reminders.
|
|
||||||
- CalDAV pull sync: runs every hour for all users with CalDAV configured.
|
|
||||||
- Chat retention cleanup: runs daily, deleting old conversations per user setting.
|
|
||||||
|
|
||||||
Uses the same BackgroundScheduler pattern as briefing_scheduler.py.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from fabledassistant.models import async_session
|
|
||||||
from fabledassistant.models.event import Event
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_scheduler: BackgroundScheduler | None = None
|
|
||||||
_loop: asyncio.AbstractEventLoop | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Reminder job
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _fire_reminders() -> None:
|
|
||||||
"""Find events with reminders due in the next 5 minutes and fire push notifications."""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
window_end = now + timedelta(minutes=5)
|
|
||||||
|
|
||||||
async with async_session() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Event).where(
|
|
||||||
Event.reminder_minutes.isnot(None),
|
|
||||||
Event.reminder_sent_at.is_(None),
|
|
||||||
Event.start_dt > now, # event hasn't started yet
|
|
||||||
# reminder fires when now >= start_dt - reminder_minutes
|
|
||||||
# i.e. start_dt <= now + reminder_minutes (approximated by window_end check)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
candidates = list(result.scalars().all())
|
|
||||||
|
|
||||||
to_notify: list[Event] = []
|
|
||||||
for event in candidates:
|
|
||||||
reminder_dt = event.start_dt - timedelta(minutes=event.reminder_minutes)
|
|
||||||
if reminder_dt <= window_end:
|
|
||||||
to_notify.append(event)
|
|
||||||
|
|
||||||
if not to_notify:
|
|
||||||
return
|
|
||||||
|
|
||||||
async with async_session() as session:
|
|
||||||
for event in to_notify:
|
|
||||||
# Push delivery removed alongside the chat subsystem in Phase 8.
|
|
||||||
# Event reminders are still flagged via in-app notifications
|
|
||||||
# (see services/notifications.py).
|
|
||||||
|
|
||||||
# Mark as sent regardless of push success to avoid re-firing
|
|
||||||
result = await session.execute(
|
|
||||||
select(Event).where(Event.id == event.id)
|
|
||||||
)
|
|
||||||
ev = result.scalar_one_or_none()
|
|
||||||
if ev:
|
|
||||||
ev.reminder_sent_at = datetime.now(timezone.utc)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_reminders(loop: asyncio.AbstractEventLoop) -> None:
|
|
||||||
asyncio.run_coroutine_threadsafe(_fire_reminders(), loop)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CalDAV pull sync job
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _run_caldav_sync() -> None:
|
|
||||||
from fabledassistant.services.caldav_sync import sync_all_users # noqa: PLC0415
|
|
||||||
try:
|
|
||||||
await sync_all_users()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("CalDAV pull sync job failed", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
|
|
||||||
asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Lifecycle
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
|
||||||
global _scheduler, _loop
|
|
||||||
if _scheduler is not None:
|
|
||||||
return
|
|
||||||
_loop = loop
|
|
||||||
_scheduler = BackgroundScheduler()
|
|
||||||
|
|
||||||
# Check reminders every 5 minutes
|
|
||||||
_scheduler.add_job(
|
|
||||||
_run_reminders,
|
|
||||||
trigger=IntervalTrigger(minutes=5),
|
|
||||||
args=[loop],
|
|
||||||
id="event_reminders",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# CalDAV pull sync every hour
|
|
||||||
_scheduler.add_job(
|
|
||||||
_run_caldav_sync_threadsafe,
|
|
||||||
trigger=IntervalTrigger(hours=1),
|
|
||||||
args=[loop],
|
|
||||||
id="caldav_pull_sync",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
_scheduler.start()
|
|
||||||
logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h)")
|
|
||||||
|
|
||||||
|
|
||||||
def stop_event_scheduler() -> None:
|
|
||||||
global _scheduler
|
|
||||||
if _scheduler is not None:
|
|
||||||
_scheduler.shutdown(wait=False)
|
|
||||||
_scheduler = None
|
|
||||||
logger.info("Event scheduler stopped")
|
|
||||||
@@ -5,29 +5,30 @@ from pathlib import Path
|
|||||||
|
|
||||||
from quart import Quart, g, jsonify, make_response, request, send_from_directory
|
from quart import Quart, g, jsonify, make_response, request, send_from_directory
|
||||||
|
|
||||||
from fabledassistant.config import Config
|
from scribe.config import Config
|
||||||
from fabledassistant.routes.admin import admin_bp
|
from scribe.routes.admin import admin_bp
|
||||||
from fabledassistant.routes.api import api
|
from scribe.routes.api import api
|
||||||
from fabledassistant.routes.auth import auth_bp
|
from scribe.routes.auth import auth_bp
|
||||||
from fabledassistant.routes.export import export_bp
|
from scribe.routes.export import export_bp
|
||||||
from fabledassistant.routes.notes import notes_bp
|
from scribe.routes.notes import notes_bp
|
||||||
from fabledassistant.routes.milestones import milestones_bp
|
from scribe.routes.milestones import milestones_bp
|
||||||
from fabledassistant.routes.task_logs import task_logs_bp
|
from scribe.routes.task_logs import task_logs_bp
|
||||||
from fabledassistant.routes.projects import projects_bp
|
from scribe.routes.projects import projects_bp
|
||||||
from fabledassistant.routes.settings import settings_bp
|
from scribe.routes.settings import settings_bp
|
||||||
from fabledassistant.routes.tasks import tasks_bp
|
from scribe.routes.tasks import tasks_bp
|
||||||
from fabledassistant.routes.groups import groups_bp
|
from scribe.routes.groups import groups_bp
|
||||||
from fabledassistant.routes.shares import shares_bp
|
from scribe.routes.shares import shares_bp
|
||||||
from fabledassistant.routes.in_app_notifications import notifications_bp
|
from scribe.routes.in_app_notifications import notifications_bp
|
||||||
from fabledassistant.routes.users import users_bp
|
from scribe.routes.users import users_bp
|
||||||
from fabledassistant.routes.api_keys import api_keys_bp
|
from scribe.routes.api_keys import api_keys_bp
|
||||||
from fabledassistant.routes.events import events_bp
|
from scribe.routes.events import events_bp
|
||||||
from fabledassistant.routes.search import search_bp
|
from scribe.routes.search import search_bp
|
||||||
from fabledassistant.routes.profile import profile_bp
|
from scribe.routes.profile import profile_bp
|
||||||
from fabledassistant.routes.knowledge import knowledge_bp
|
from scribe.routes.knowledge import knowledge_bp
|
||||||
from fabledassistant.routes.rulebooks import rulebooks_bp
|
from scribe.routes.rulebooks import rulebooks_bp
|
||||||
from fabledassistant.routes.trash import trash_bp
|
from scribe.routes.trash import trash_bp
|
||||||
from fabledassistant.mcp import mount_mcp
|
from scribe.routes.dashboard import dashboard_bp
|
||||||
|
from scribe.mcp import mount_mcp
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -87,6 +88,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(knowledge_bp)
|
app.register_blueprint(knowledge_bp)
|
||||||
app.register_blueprint(rulebooks_bp)
|
app.register_blueprint(rulebooks_bp)
|
||||||
app.register_blueprint(trash_bp)
|
app.register_blueprint(trash_bp)
|
||||||
|
app.register_blueprint(dashboard_bp)
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
async def before_request():
|
async def before_request():
|
||||||
@@ -113,7 +115,7 @@ def create_app() -> Quart:
|
|||||||
# Log usage for API requests (skip logs endpoint to avoid recursion)
|
# Log usage for API requests (skip logs endpoint to avoid recursion)
|
||||||
if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"):
|
if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"):
|
||||||
try:
|
try:
|
||||||
from fabledassistant.services.logging import log_usage
|
from scribe.services.logging import log_usage
|
||||||
|
|
||||||
user = getattr(g, "user", None)
|
user = getattr(g, "user", None)
|
||||||
await log_usage(
|
await log_usage(
|
||||||
@@ -148,12 +150,14 @@ def create_app() -> Quart:
|
|||||||
async def startup():
|
async def startup():
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from fabledassistant.services.embeddings import backfill_note_embeddings
|
from scribe.services.auth import start_auth_token_retention_loop
|
||||||
from fabledassistant.services.logging import start_log_retention_loop
|
from scribe.services.embeddings import backfill_note_embeddings
|
||||||
from fabledassistant.services.notifications import start_notification_loop
|
from scribe.services.logging import start_log_retention_loop
|
||||||
|
from scribe.services.notifications import start_notification_loop
|
||||||
|
|
||||||
start_log_retention_loop()
|
start_log_retention_loop()
|
||||||
start_notification_loop()
|
start_notification_loop()
|
||||||
|
start_auth_token_retention_loop()
|
||||||
|
|
||||||
# Backfill embeddings for any notes that don't have one. Runs in the
|
# Backfill embeddings for any notes that don't have one. Runs in the
|
||||||
# background so it never blocks the server from accepting requests.
|
# background so it never blocks the server from accepting requests.
|
||||||
@@ -166,36 +170,36 @@ def create_app() -> Quart:
|
|||||||
asyncio.create_task(_delayed_backfill())
|
asyncio.create_task(_delayed_backfill())
|
||||||
|
|
||||||
# Event scheduler (reminders + CalDAV pull sync)
|
# Event scheduler (reminders + CalDAV pull sync)
|
||||||
from fabledassistant.services.event_scheduler import start_event_scheduler
|
from scribe.services.event_scheduler import start_event_scheduler
|
||||||
start_event_scheduler(asyncio.get_running_loop())
|
start_event_scheduler(asyncio.get_running_loop())
|
||||||
|
|
||||||
# Version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
|
# Version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
|
||||||
from fabledassistant.services.version_pinning_scheduler import (
|
from scribe.services.version_pinning_scheduler import (
|
||||||
start_version_pinning_scheduler,
|
start_version_pinning_scheduler,
|
||||||
)
|
)
|
||||||
start_version_pinning_scheduler(asyncio.get_running_loop())
|
start_version_pinning_scheduler(asyncio.get_running_loop())
|
||||||
|
|
||||||
# Trash retention scheduler (daily expired-trash purge at 03:30 UTC)
|
# Trash retention scheduler (daily expired-trash purge at 03:30 UTC)
|
||||||
from fabledassistant.services.trash_scheduler import start_trash_scheduler
|
from scribe.services.trash_scheduler import start_trash_scheduler
|
||||||
start_trash_scheduler(asyncio.get_running_loop())
|
start_trash_scheduler(asyncio.get_running_loop())
|
||||||
|
|
||||||
# Diagnostic instrumentation — heartbeat, signal handlers, asyncio
|
# Diagnostic instrumentation — heartbeat, signal handlers, asyncio
|
||||||
# exception hook. Cheap (~1 log line/min), high diagnostic value when
|
# exception hook. Cheap (~1 log line/min), high diagnostic value when
|
||||||
# the app crashes mysteriously. See services/diagnostics.py.
|
# the app crashes mysteriously. See services/diagnostics.py.
|
||||||
from fabledassistant.services.diagnostics import start_diagnostics
|
from scribe.services.diagnostics import start_diagnostics
|
||||||
start_diagnostics(asyncio.get_running_loop())
|
start_diagnostics(asyncio.get_running_loop())
|
||||||
|
|
||||||
@app.after_serving
|
@app.after_serving
|
||||||
async def shutdown():
|
async def shutdown():
|
||||||
from fabledassistant.services.event_scheduler import stop_event_scheduler
|
from scribe.services.event_scheduler import stop_event_scheduler
|
||||||
stop_event_scheduler()
|
stop_event_scheduler()
|
||||||
from fabledassistant.services.version_pinning_scheduler import (
|
from scribe.services.version_pinning_scheduler import (
|
||||||
stop_version_pinning_scheduler,
|
stop_version_pinning_scheduler,
|
||||||
)
|
)
|
||||||
stop_version_pinning_scheduler()
|
stop_version_pinning_scheduler()
|
||||||
from fabledassistant.services.trash_scheduler import stop_trash_scheduler
|
from scribe.services.trash_scheduler import stop_trash_scheduler
|
||||||
stop_trash_scheduler()
|
stop_trash_scheduler()
|
||||||
from fabledassistant.services.diagnostics import stop_diagnostics
|
from scribe.services.diagnostics import stop_diagnostics
|
||||||
stop_diagnostics()
|
stop_diagnostics()
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
@@ -245,7 +249,7 @@ def create_app() -> Quart:
|
|||||||
logger.exception("Internal server error on %s %s", request.method, request.path)
|
logger.exception("Internal server error on %s %s", request.method, request.path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fabledassistant.services.logging import log_error
|
from scribe.services.logging import log_error
|
||||||
|
|
||||||
user = getattr(g, "user", None)
|
user = getattr(g, "user", None)
|
||||||
await log_error(
|
await log_error(
|
||||||
@@ -2,8 +2,8 @@ import functools
|
|||||||
|
|
||||||
from quart import g, jsonify, request, session
|
from quart import g, jsonify, request, session
|
||||||
|
|
||||||
from fabledassistant.services.auth import get_user_by_id
|
from scribe.services.auth import get_user_by_id
|
||||||
from fabledassistant.services.api_keys import lookup_key
|
from scribe.services.api_keys import lookup_key
|
||||||
|
|
||||||
|
|
||||||
def _check_auth(f, required_role: str | None = None):
|
def _check_auth(f, required_role: str | None = None):
|
||||||
@@ -20,7 +20,7 @@ class Config:
|
|||||||
DATABASE_URL: str = _read_secret(
|
DATABASE_URL: str = _read_secret(
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"DATABASE_URL_FILE",
|
"DATABASE_URL_FILE",
|
||||||
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
|
"postgresql+asyncpg://scribe:scribe@localhost:5432/scribe",
|
||||||
)
|
)
|
||||||
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
|
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
|
||||||
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
|
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
Auth uses the existing `api_keys` table: a Bearer token in the Authorization
|
Auth uses the existing `api_keys` table: a Bearer token in the Authorization
|
||||||
header resolves to a user, and every tool call acts on that user's data only.
|
header resolves to a user, and every tool call acts on that user's data only.
|
||||||
"""
|
"""
|
||||||
from fabledassistant.mcp.server import build_mcp_server, mount_mcp
|
from scribe.mcp.server import build_mcp_server, mount_mcp
|
||||||
|
|
||||||
__all__ = ["build_mcp_server", "mount_mcp"]
|
__all__ = ["build_mcp_server", "mount_mcp"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""MCP-side Bearer token resolution. Reuses the existing api_keys infrastructure."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from scribe.services.api_keys import lookup_key
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
|
||||||
|
"""Parse an `Authorization: Bearer <token>` header and return the user_id.
|
||||||
|
|
||||||
|
Returns None if the header is missing, malformed, or the token is invalid
|
||||||
|
or revoked. The underlying lookup_key already updates last_used_at on hit.
|
||||||
|
"""
|
||||||
|
if not auth_header or not auth_header.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
raw_token = auth_header[len("Bearer "):].strip()
|
||||||
|
if not raw_token:
|
||||||
|
return None
|
||||||
|
api_key = await lookup_key(raw_token)
|
||||||
|
return api_key.user_id if api_key else None
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_bearer(auth_header: str | None) -> tuple[int, str] | None:
|
||||||
|
"""Resolve a Bearer token to (user_id, scope).
|
||||||
|
|
||||||
|
scope is 'read' or 'write'. Returns None for a missing/malformed/invalid
|
||||||
|
token. The MCP dispatch layer uses scope to deny write-class tool calls
|
||||||
|
from read-only keys — the same read/write boundary the REST API enforces.
|
||||||
|
"""
|
||||||
|
if not auth_header or not auth_header.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
raw_token = auth_header[len("Bearer "):].strip()
|
||||||
|
if not raw_token:
|
||||||
|
return None
|
||||||
|
api_key = await lookup_key(raw_token)
|
||||||
|
if api_key is None:
|
||||||
|
return None
|
||||||
|
return api_key.user_id, (api_key.scope or "write")
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"""FastMCP instance + Quart mount-point. Tools are registered in mcp/tools/."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
|
from quart import Quart
|
||||||
|
|
||||||
|
_INSTRUCTIONS = """
|
||||||
|
Scribe is the user's self-hosted second-brain and project-management data
|
||||||
|
store. You (Claude) are the assistant.
|
||||||
|
|
||||||
|
Hierarchy: Project -> Milestone -> Task/Note.
|
||||||
|
|
||||||
|
What each part is for, and when to reach for it:
|
||||||
|
- Project: the top-level container for a body of work.
|
||||||
|
- Milestone: groups related tasks within a project toward a goal (status
|
||||||
|
active/done). Use one when a chunk of work needs its own arc.
|
||||||
|
- Task: a unit of actionable work with a lifecycle (status
|
||||||
|
todo/in_progress/done/cancelled, optional priority). A task is a note with a
|
||||||
|
status — reach for one when there is something to DO. Record progress over
|
||||||
|
time with work-logs (add_task_log) rather than rewriting the body.
|
||||||
|
- Plan: a task with kind=plan — HOW you'll execute a chunk of work. The body
|
||||||
|
holds the design + step checklist; work-logs record progress. Start one with
|
||||||
|
start_planning when beginning non-trivial work, before writing code.
|
||||||
|
- Note: durable free-form knowledge — reference material, decisions, dev-logs.
|
||||||
|
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
|
||||||
|
- Typed entities (person/place/list): structured records about people, places,
|
||||||
|
and checklists.
|
||||||
|
|
||||||
|
Mechanics:
|
||||||
|
- Notes and Tasks share a model; tasks are notes with is_task=True.
|
||||||
|
- Use the *_note tools for notes, the *_task tools for tasks. Don't mix them.
|
||||||
|
- Typed entities (person, place, list) are notes with a non-default note_type
|
||||||
|
plus type-specific columns; use the dedicated *_person / *_place / *_list
|
||||||
|
tools rather than create_note.
|
||||||
|
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
|
||||||
|
unchanged on updates.
|
||||||
|
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
|
||||||
|
"not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1
|
||||||
|
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:
|
||||||
|
- When you begin working a task, set it to in_progress (update_task
|
||||||
|
status=in_progress).
|
||||||
|
- Log progress as you go with add_task_log — at meaningful steps, not saved up
|
||||||
|
for the end.
|
||||||
|
- The moment a task's work is complete, set it done. Never leave finished work
|
||||||
|
at todo/in_progress — an out-of-date status makes Scribe misrepresent what's
|
||||||
|
left to do.
|
||||||
|
- At a significant landing (a merge, a shipped feature, a finished plan), write
|
||||||
|
a short dated dev-log note on the project (create_note) summarizing what
|
||||||
|
landed, and mark the plan/task done.
|
||||||
|
|
||||||
|
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
|
||||||
|
an actionable statement plus optional Why and How-to-apply context. At the
|
||||||
|
start of any session that touches Scribe, call list_always_on_rules() to
|
||||||
|
load the standing rules — treat them as binding. When you also have a project
|
||||||
|
in scope, get_project(id) returns applicable_rules (rules from rulebooks the
|
||||||
|
project subscribes to) and subscribed_rulebooks; consult those too. Full text
|
||||||
|
(Why / How-to-apply) is available via get_rule(id).
|
||||||
|
|
||||||
|
Engineering and workflow rules live in Scribe. When you notice a pattern
|
||||||
|
worth codifying, call create_rule (cross-project, lands in a rulebook+topic)
|
||||||
|
or create_project_rule (one project only, no rulebook ceremony). Do NOT add
|
||||||
|
new engineering rules to CLAUDE.md or to ~/.claude/.../memory/feedback_*.md
|
||||||
|
— those stores are reserved for facts about the user (preferences, role,
|
||||||
|
communication style) and codebase onboarding pointers, respectively. Before
|
||||||
|
creating a rule, call list_always_on_rules and list_rules(project_id=...) to
|
||||||
|
avoid duplicates. Coordinate with the operator on whether a new rule belongs
|
||||||
|
in a project, an existing rulebook+topic, or a new rulebook.
|
||||||
|
|
||||||
|
That boundary cuts the other way too. Because rules are pull-only, a fresh
|
||||||
|
session won't reach for them unless its always-loaded context says to. So
|
||||||
|
when the current project subscribes to a rulebook (enter_project /
|
||||||
|
get_project reports subscribed_rulebooks), make sure the host's persistent
|
||||||
|
memory — the CLAUDE.md / AGENTS.md / ~/.claude memory the client loads at
|
||||||
|
startup — carries a short pointer: that the engineering and workflow rules
|
||||||
|
for this work live in Scribe and must be loaded via list_always_on_rules (or
|
||||||
|
enter_project when a project is in scope), plus a one-line note of what the
|
||||||
|
current project is and what is in flight. Add or refresh that pointer when
|
||||||
|
it's missing or stale; never copy the rules' content into memory — the
|
||||||
|
pointer plus project context is the whole job. This is what lets the next
|
||||||
|
session reach for Scribe instead of trusting a stale local copy.
|
||||||
|
|
||||||
|
When you are working on a specific project, call enter_project(project_id)
|
||||||
|
ONCE at session start (or whenever the active project changes). It returns the
|
||||||
|
project, its applicable_rules + project_rules + subscribed_rulebooks, milestone
|
||||||
|
summary, open tasks, and recent notes — everything you need to know the lay of
|
||||||
|
the land before mutating. Don't call get_project + get_applicable_rules + a
|
||||||
|
search separately when enter_project already composes them.
|
||||||
|
|
||||||
|
Don't wait to be told which project you're in. At the start of a session that
|
||||||
|
touches Scribe — or the moment work clearly belongs to a project but none is in
|
||||||
|
scope — bootstrap project context proactively: search for a related existing
|
||||||
|
project (search / list_projects, matching on the work's subject, the repo or
|
||||||
|
directory name, and recent activity). If you find a confident match, propose it
|
||||||
|
and call enter_project once the operator confirms. If nothing matches, offer to
|
||||||
|
create a project, confirming its name and goal first. Always confirm before
|
||||||
|
adopting or creating — never do either silently, and never guess a project into
|
||||||
|
existence. Once a project is in scope, the enter_project handshake and the
|
||||||
|
host-memory pointer step above both apply.
|
||||||
|
|
||||||
|
Plans are tasks with kind=plan, and Scribe is the canonical home for them.
|
||||||
|
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
|
||||||
|
before any brainstorming, design, or plan-writing skill runs. start_planning
|
||||||
|
seeds the plan body, returns the project's applicable_rules, and gives you the
|
||||||
|
task id you'll write into. If a skill or habit tells you to save a plan or spec
|
||||||
|
to `docs/superpowers/plans/*.md` or `docs/superpowers/specs/*.md`, that path is
|
||||||
|
superseded here: put the spec/plan content in the kind=plan task's body via
|
||||||
|
update_task, and record progress with add_task_log. Local .md files are not
|
||||||
|
the record — the task is.
|
||||||
|
|
||||||
|
Deletes are recoverable: every delete_* tool moves the entity (and its
|
||||||
|
descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
|
||||||
|
see trashed batches, restore(deleted_batch_id) to undo a deletion, and
|
||||||
|
purge_trash(deleted_batch_id, confirmed=True) for a permanent delete. Trash
|
||||||
|
auto-purges after the operator's retention window.
|
||||||
|
|
||||||
|
Scribe stores reusable Processes — saved prompts/workflows (note_type
|
||||||
|
"process"), e.g. a drift audit or a DRY pass. When the operator says "run the
|
||||||
|
X process" or otherwise references a saved process, call list_processes() /
|
||||||
|
get_process(name) and follow the returned prompt verbatim, including any
|
||||||
|
"clarify first" steps it contains. Author a new one with create_process(title,
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Tools a read-only API key may call. Anything not listed is treated as a
|
||||||
|
# write for read keys (default-deny), so a newly-added tool is locked down
|
||||||
|
# until explicitly classified here.
|
||||||
|
_READ_ONLY_TOOLS = frozenset({
|
||||||
|
"get_event", "get_note", "get_project", "get_rule", "get_rulebook",
|
||||||
|
"get_task", "get_recent", "enter_project",
|
||||||
|
"list_events", "list_lists", "list_milestones", "list_notes",
|
||||||
|
"list_persons", "list_places", "list_projects", "list_rulebooks",
|
||||||
|
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
|
||||||
|
"list_always_on_rules", "search",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def _buffer_request_body(receive):
|
||||||
|
"""Drain the ASGI request body and return (body_bytes, replay_receive).
|
||||||
|
|
||||||
|
The MCP sub-app still needs to read the body, so we return a fresh
|
||||||
|
`receive` that replays the buffered bytes.
|
||||||
|
"""
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
more = True
|
||||||
|
while more:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.request":
|
||||||
|
chunks.append(message.get("body", b""))
|
||||||
|
more = message.get("more_body", False)
|
||||||
|
else: # http.disconnect
|
||||||
|
more = False
|
||||||
|
body = b"".join(chunks)
|
||||||
|
|
||||||
|
sent = False
|
||||||
|
|
||||||
|
async def replay():
|
||||||
|
nonlocal sent
|
||||||
|
if not sent:
|
||||||
|
sent = True
|
||||||
|
return {"type": "http.request", "body": body, "more_body": False}
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
|
||||||
|
return body, replay
|
||||||
|
|
||||||
|
|
||||||
|
def _body_calls_write_tool(body: bytes) -> bool:
|
||||||
|
"""True if the JSON-RPC body invokes a tool outside the read all-list."""
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
payload = json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
items = payload if isinstance(payload, list) else [payload]
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if item.get("method") == "tools/call":
|
||||||
|
name = (item.get("params") or {}).get("name", "")
|
||||||
|
if name and name not in _READ_ONLY_TOOLS:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def build_mcp_server() -> FastMCP:
|
||||||
|
"""Build the FastMCP instance with all tools registered.
|
||||||
|
|
||||||
|
DNS-rebinding protection is disabled: FastMCP's default allow-list is
|
||||||
|
just localhost variants, which means any deployment behind a reverse
|
||||||
|
proxy (Traefik with a hostname like devassistant.traefik.internal,
|
||||||
|
Cloudflare, nginx, etc.) gets 421 Misdirected Request. The threat
|
||||||
|
model that protection addresses — a malicious browser page rebinding
|
||||||
|
DNS to hit a localhost MCP — doesn't apply here: this is HTTP transport
|
||||||
|
behind a reverse proxy with bearer-token auth as the real security
|
||||||
|
boundary.
|
||||||
|
"""
|
||||||
|
# stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
|
||||||
|
# The stateful default strands Claude Code after a container redeploy —
|
||||||
|
# it reconnects with the now-unknown session id, the server returns 404,
|
||||||
|
# and the client won't re-initialize on a 404 (Claude Code issue #60949),
|
||||||
|
# so the connection stays dead until a manual /mcp retry. Stateless makes
|
||||||
|
# every request self-contained (bearer-auth only), so a post-deploy
|
||||||
|
# reconnect just works. Trade-off: no server-pushed list_changed stream,
|
||||||
|
# which we don't use — tools are re-fetched on reconnect anyway.
|
||||||
|
mcp = FastMCP(
|
||||||
|
"scribe",
|
||||||
|
instructions=_INSTRUCTIONS.strip(),
|
||||||
|
stateless_http=True,
|
||||||
|
transport_security=TransportSecuritySettings(
|
||||||
|
enable_dns_rebinding_protection=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
from scribe.mcp.tools import register_all
|
||||||
|
register_all(mcp)
|
||||||
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
def mount_mcp(app: Quart) -> None:
|
||||||
|
"""Mount the FastMCP streamable-HTTP ASGI sub-app at /mcp on the Quart app.
|
||||||
|
|
||||||
|
A small ASGI middleware between Quart and the FastMCP sub-app validates the
|
||||||
|
Bearer token against the api_keys table. Authenticated requests have their
|
||||||
|
user_id attached to the ASGI scope under "scribe_user_id" for tool handlers
|
||||||
|
to read.
|
||||||
|
|
||||||
|
FastMCP's streamable_http session manager owns a task group that must be
|
||||||
|
running before it can serve requests. In a stand-alone Starlette deployment
|
||||||
|
that would happen via the Starlette `lifespan` parameter. Since we're hosted
|
||||||
|
inside Quart, we hook the session manager's `run()` async context manager
|
||||||
|
into Quart's serving lifecycle (before_serving / after_serving).
|
||||||
|
"""
|
||||||
|
from scribe.mcp.auth import resolve_bearer
|
||||||
|
|
||||||
|
mcp = build_mcp_server()
|
||||||
|
mcp_asgi = mcp.streamable_http_app()
|
||||||
|
app.mcp_instance = mcp
|
||||||
|
|
||||||
|
@app.before_serving
|
||||||
|
async def _start_mcp_session() -> None:
|
||||||
|
cm = mcp.session_manager.run()
|
||||||
|
await cm.__aenter__()
|
||||||
|
app._mcp_session_cm = cm
|
||||||
|
|
||||||
|
@app.after_serving
|
||||||
|
async def _stop_mcp_session() -> None:
|
||||||
|
cm = getattr(app, "_mcp_session_cm", None)
|
||||||
|
if cm is not None:
|
||||||
|
await cm.__aexit__(None, None, None)
|
||||||
|
|
||||||
|
async def auth_wrapped(scope, receive, send):
|
||||||
|
if scope["type"] != "http":
|
||||||
|
return await mcp_asgi(scope, receive, send)
|
||||||
|
# ASGI headers are lowercase bytes per spec; lowercase explicitly to be safe.
|
||||||
|
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
|
||||||
|
resolved = await resolve_bearer(headers.get("authorization"))
|
||||||
|
if resolved is None:
|
||||||
|
await send({
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": 401,
|
||||||
|
"headers": [
|
||||||
|
(b"content-type", b"application/json"),
|
||||||
|
(b"www-authenticate", b'Bearer realm="scribe-mcp"'),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await send({
|
||||||
|
"type": "http.response.body",
|
||||||
|
"body": b'{"error":"unauthorized"}',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
user_id, key_scope = resolved
|
||||||
|
|
||||||
|
# Enforce read-only keys: REST blocks non-GET for scope='read', and the
|
||||||
|
# MCP surface must match or the read-only guarantee is void. A tool call
|
||||||
|
# arrives as a JSON-RPC POST; buffer the body, and if it invokes a tool
|
||||||
|
# outside the read all-list, reject before dispatch. (default-deny: any
|
||||||
|
# unknown/new tool is treated as a write for read keys.)
|
||||||
|
if key_scope == "read" and scope.get("method") == "POST":
|
||||||
|
body, receive = await _buffer_request_body(receive)
|
||||||
|
if _body_calls_write_tool(body):
|
||||||
|
await send({
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": 403,
|
||||||
|
"headers": [(b"content-type", b"application/json")],
|
||||||
|
})
|
||||||
|
await send({
|
||||||
|
"type": "http.response.body",
|
||||||
|
"body": b'{"error":"read-only API key cannot call write tools"}',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
scope["scribe_user_id"] = user_id
|
||||||
|
from scribe.mcp._context import _user_id_ctx
|
||||||
|
token = _user_id_ctx.set(user_id)
|
||||||
|
try:
|
||||||
|
await mcp_asgi(scope, receive, send)
|
||||||
|
finally:
|
||||||
|
_user_id_ctx.reset(token)
|
||||||
|
|
||||||
|
original_asgi = app.asgi_app
|
||||||
|
|
||||||
|
async def dispatch(scope, receive, send):
|
||||||
|
if scope["type"] == "http":
|
||||||
|
path = scope.get("path", "")
|
||||||
|
if path == "/mcp" or path.startswith("/mcp/"):
|
||||||
|
# Don't rewrite the path: FastMCP's streamable_http_app mounts
|
||||||
|
# its handler at /mcp by default. If we strip the prefix to "/",
|
||||||
|
# FastMCP's internal routing returns 404 because there's no
|
||||||
|
# handler at "/" — only at "/mcp". Pass the scope through
|
||||||
|
# untouched and let FastMCP's own routing match.
|
||||||
|
return await auth_wrapped(scope, receive, send)
|
||||||
|
return await original_asgi(scope, receive, send)
|
||||||
|
|
||||||
|
app.asgi_app = dispatch
|
||||||
@@ -4,8 +4,8 @@ Each tool module exposes a `register(mcp)` function that attaches its tools
|
|||||||
to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
to a FastMCP instance. `register_all(mcp)` is the single entry point called
|
||||||
from `mcp.server.build_mcp_server`.
|
from `mcp.server.build_mcp_server`.
|
||||||
"""
|
"""
|
||||||
from fabledassistant.mcp.tools import (
|
from scribe.mcp.tools import (
|
||||||
entities, events, milestones, notes, projects, recent, rulebooks, search, tags, tasks, trash,
|
entities, events, milestones, notes, processes, projects, recent, rulebooks, search, tags, tasks, trash,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -20,5 +20,6 @@ def register_all(mcp) -> None:
|
|||||||
tags.register(mcp)
|
tags.register(mcp)
|
||||||
recent.register(mcp)
|
recent.register(mcp)
|
||||||
entities.register(mcp)
|
entities.register(mcp)
|
||||||
|
processes.register(mcp)
|
||||||
rulebooks.register(mcp)
|
rulebooks.register(mcp)
|
||||||
trash.register(mcp)
|
trash.register(mcp)
|
||||||
@@ -14,9 +14,9 @@ of plain strings for ergonomics; checked-state is reset to False on each call.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from fabledassistant.services import knowledge as knowledge_svc
|
from scribe.services import knowledge as knowledge_svc
|
||||||
from fabledassistant.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
|
|
||||||
|
|
||||||
async def _list_by_type(note_type: str, q: str, tag: str, limit: int) -> dict:
|
async def _list_by_type(note_type: str, q: str, tag: str, limit: int) -> dict:
|
||||||
@@ -19,9 +19,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from fabledassistant.services import events as events_svc
|
from scribe.services import events as events_svc
|
||||||
from fabledassistant.services import trash as trash_svc
|
from scribe.services import trash as trash_svc
|
||||||
|
|
||||||
|
|
||||||
def _combine(start_date: str, start_time: str) -> datetime:
|
def _combine(start_date: str, start_time: str) -> datetime:
|
||||||
@@ -11,9 +11,9 @@ Sentinels:
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from fabledassistant.services import milestones as milestones_svc
|
from scribe.services import milestones as milestones_svc
|
||||||
from fabledassistant.services import trash as trash_svc
|
from scribe.services import trash as trash_svc
|
||||||
|
|
||||||
|
|
||||||
async def list_milestones(project_id: int) -> dict:
|
async def list_milestones(project_id: int) -> dict:
|
||||||
@@ -13,9 +13,9 @@ Sentinel conventions (inherited from existing fable-mcp tools):
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from fabledassistant.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from fabledassistant.services import trash as trash_svc
|
from scribe.services import trash as trash_svc
|
||||||
|
|
||||||
|
|
||||||
async def list_notes(
|
async def list_notes(
|
||||||
@@ -23,6 +23,7 @@ async def list_notes(
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
tag: str = "",
|
tag: str = "",
|
||||||
search_text: str = "",
|
search_text: str = "",
|
||||||
|
project_id: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List notes (non-task documents) stored in Scribe.
|
"""List notes (non-task documents) stored in Scribe.
|
||||||
|
|
||||||
@@ -30,6 +31,12 @@ async def list_notes(
|
|||||||
search against title and body. Results are ordered by last-updated descending.
|
search against title and body. Results are ordered by last-updated descending.
|
||||||
|
|
||||||
Use search for semantic/meaning-based lookup instead of exact keyword search.
|
Use search for semantic/meaning-based lookup instead of exact keyword search.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: Scope to one project. PASS THE ACTIVE PROJECT'S ID whenever a
|
||||||
|
project is in scope so you list that project's notes, not every
|
||||||
|
project's. 0 = no filter (all projects — use only for a deliberate
|
||||||
|
cross-project view).
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
rows, total = await notes_svc.list_notes(
|
rows, total = await notes_svc.list_notes(
|
||||||
@@ -37,6 +44,7 @@ async def list_notes(
|
|||||||
q=search_text or None,
|
q=search_text or None,
|
||||||
tags=[tag] if tag else None,
|
tags=[tag] if tag else None,
|
||||||
is_task=False,
|
is_task=False,
|
||||||
|
project_id=project_id or None,
|
||||||
limit=max(1, min(limit, 100)),
|
limit=max(1, min(limit, 100)),
|
||||||
offset=max(0, offset),
|
offset=max(0, offset),
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Stored-process MCP tools: reusable saved prompts (note_type='process').
|
||||||
|
|
||||||
|
A process is a Note whose body is a prompt the operator fires later
|
||||||
|
("run the X process"). Mirrors entities.py — the tools wrap notes_svc directly.
|
||||||
|
get_process is the fire mechanism: it returns the full prompt for Claude to run.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from scribe.mcp._context import current_user_id
|
||||||
|
from scribe.services import knowledge as knowledge_svc
|
||||||
|
from scribe.services import notes as notes_svc
|
||||||
|
|
||||||
|
|
||||||
|
async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
|
||||||
|
"""List stored processes (reusable saved prompts).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
q: Free-text search across title + body (optional).
|
||||||
|
tag: Filter to a single tag (optional).
|
||||||
|
limit: Max results (1-100).
|
||||||
|
|
||||||
|
Returns {"processes": [{id, title, tags, preview}], "total": int}.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
items, total = await knowledge_svc.query_knowledge(
|
||||||
|
user_id=uid, note_type="process", tags=[tag] if tag else [],
|
||||||
|
sort="modified", q=q or None, limit=max(1, min(limit, 100)), offset=0,
|
||||||
|
)
|
||||||
|
procs = [{"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
|
||||||
|
"preview": it.get("snippet", "")} for it in items]
|
||||||
|
return {"processes": procs, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_process(title: str, body: str, tags: list[str] | None = None) -> dict:
|
||||||
|
"""Create a stored process (a reusable saved prompt).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Process name, e.g. "Drift Audit" (required).
|
||||||
|
body: The full prompt to run later (markdown). Required.
|
||||||
|
tags: Plain-string tags, no # prefix.
|
||||||
|
"""
|
||||||
|
if not (title or "").strip() or not (body or "").strip():
|
||||||
|
raise ValueError("create_process requires a non-empty title and body")
|
||||||
|
uid = current_user_id()
|
||||||
|
note = await notes_svc.create_note(
|
||||||
|
uid, title=title.strip(), body=body, note_type="process", tags=tags,
|
||||||
|
)
|
||||||
|
return note.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_process(name_or_id: str) -> dict:
|
||||||
|
"""Fetch a stored process by name or id and return its full prompt — the
|
||||||
|
fire mechanism. The operator says "run the <name> process"; call this and
|
||||||
|
follow the returned body (including any 'clarify first' steps it contains).
|
||||||
|
|
||||||
|
Resolution: numeric id → exact (case-insensitive) title → substring. On an
|
||||||
|
ambiguous substring match, the best (most-recent) match is returned with an
|
||||||
|
`other_matches` list so you can disambiguate with the operator.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
note, candidates = await notes_svc.resolve_process(uid, name_or_id)
|
||||||
|
if note is None:
|
||||||
|
raise ValueError(f"process {name_or_id!r} not found")
|
||||||
|
out = note.to_dict()
|
||||||
|
if candidates:
|
||||||
|
out["other_matches"] = candidates
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def update_process(process_id: int, title: str = "", body: str = "",
|
||||||
|
tags: list[str] | None = None) -> dict:
|
||||||
|
"""Update a stored process. Only provided fields change — empty title/body
|
||||||
|
leave that field unchanged; pass tags to replace the tag set."""
|
||||||
|
uid = current_user_id()
|
||||||
|
note = await notes_svc.get_note(uid, process_id)
|
||||||
|
if note is None or note.note_type != "process":
|
||||||
|
raise ValueError(f"process {process_id} not found")
|
||||||
|
fields: dict = {}
|
||||||
|
if title.strip():
|
||||||
|
fields["title"] = title.strip()
|
||||||
|
if body.strip():
|
||||||
|
fields["body"] = body
|
||||||
|
if tags is not None:
|
||||||
|
fields["tags"] = tags
|
||||||
|
updated = await notes_svc.update_note(uid, process_id, **fields)
|
||||||
|
return updated.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def register(mcp) -> None:
|
||||||
|
for fn in (list_processes, create_process, get_process, update_process):
|
||||||
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
@@ -16,17 +16,18 @@ keeps working.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from fabledassistant.services import milestones as milestones_svc
|
from scribe.services import milestones as milestones_svc
|
||||||
from fabledassistant.services import projects as projects_svc
|
from scribe.services import notes as notes_svc
|
||||||
from fabledassistant.services import rulebooks as rulebooks_svc
|
from scribe.services import projects as projects_svc
|
||||||
from fabledassistant.services import trash as trash_svc
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
|
from scribe.services import trash as trash_svc
|
||||||
|
|
||||||
|
|
||||||
async def list_projects() -> dict:
|
async def list_projects() -> dict:
|
||||||
"""List all Scribe projects for the current user.
|
"""List all Scribe projects for the current user.
|
||||||
|
|
||||||
Returns id, title, description, goal, status (active/archived), color,
|
Returns id, title, description, goal, status (active/paused/completed/archived), color,
|
||||||
and a short auto-generated summary for each project.
|
and a short auto-generated summary for each project.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
@@ -34,6 +35,73 @@ async def list_projects() -> dict:
|
|||||||
return {"projects": [p.to_dict() for p in rows]}
|
return {"projects": [p.to_dict() for p in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
async def enter_project(project_id: int) -> dict:
|
||||||
|
"""Session-start handshake: load full context for working on a project.
|
||||||
|
|
||||||
|
Call this FIRST whenever you're about to do project-scoped work
|
||||||
|
(start_planning, create_task, update_*, anything that takes a project_id).
|
||||||
|
One round-trip returns the project, its applicable rules (both rulebook-
|
||||||
|
subscribed and project-scoped), milestone progress, open tasks, and
|
||||||
|
recently-updated notes — everything you need to know the lay of the land
|
||||||
|
before mutating.
|
||||||
|
|
||||||
|
No persistent server state: this is a read snapshot. Re-call if the
|
||||||
|
session goes idle long enough that the data feels stale.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project to enter.
|
||||||
|
|
||||||
|
Returns a dict with keys: project, milestone_summary, applicable_rules,
|
||||||
|
project_rules, subscribed_rulebooks, applicable_rules_truncated,
|
||||||
|
open_tasks, recent_notes.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
project = await projects_svc.get_project(uid, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise ValueError(f"project {project_id} not found")
|
||||||
|
|
||||||
|
applicable = await rulebooks_svc.get_applicable_rules(
|
||||||
|
project_id=project_id, user_id=uid,
|
||||||
|
)
|
||||||
|
milestone_summary = await milestones_svc.get_project_milestone_summary(
|
||||||
|
uid, project_id,
|
||||||
|
)
|
||||||
|
open_tasks, _ = await notes_svc.list_notes(
|
||||||
|
uid, is_task=True, project_id=project_id,
|
||||||
|
status=["todo", "in_progress"], sort="updated_at", limit=10,
|
||||||
|
)
|
||||||
|
recent_notes, _ = await notes_svc.list_notes(
|
||||||
|
uid, is_task=False, project_id=project_id,
|
||||||
|
sort="updated_at", limit=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project": project.to_dict(),
|
||||||
|
"milestone_summary": milestone_summary,
|
||||||
|
"applicable_rules": applicable["rules"],
|
||||||
|
"project_rules": applicable.get("project_rules", []),
|
||||||
|
"suppressed_rules": applicable.get("suppressed_rules", []),
|
||||||
|
"suppressed_topics": applicable.get("suppressed_topics", []),
|
||||||
|
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
|
||||||
|
"applicable_rules_truncated": applicable["truncated"],
|
||||||
|
"open_tasks": [
|
||||||
|
{
|
||||||
|
"id": t.id, "title": t.title, "status": t.status,
|
||||||
|
"priority": t.priority, "task_kind": t.task_kind,
|
||||||
|
"milestone_id": t.milestone_id,
|
||||||
|
}
|
||||||
|
for t in open_tasks
|
||||||
|
],
|
||||||
|
"recent_notes": [
|
||||||
|
{
|
||||||
|
"id": n.id, "title": n.title,
|
||||||
|
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
|
||||||
|
}
|
||||||
|
for n in recent_notes
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_project(project_id: int) -> dict:
|
async def get_project(project_id: int) -> dict:
|
||||||
"""Fetch a Scribe project by ID.
|
"""Fetch a Scribe project by ID.
|
||||||
|
|
||||||
@@ -55,6 +123,9 @@ async def get_project(project_id: int) -> dict:
|
|||||||
data["applicable_rules"] = applicable["rules"]
|
data["applicable_rules"] = applicable["rules"]
|
||||||
data["applicable_rules_truncated"] = applicable["truncated"]
|
data["applicable_rules_truncated"] = applicable["truncated"]
|
||||||
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
|
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
|
||||||
|
data["project_rules"] = applicable.get("project_rules", [])
|
||||||
|
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
|
||||||
|
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -71,7 +142,7 @@ async def create_project(
|
|||||||
title: Project name (required).
|
title: Project name (required).
|
||||||
description: Short summary of what the project is.
|
description: Short summary of what the project is.
|
||||||
goal: The desired outcome or definition of done for the project.
|
goal: The desired outcome or definition of done for the project.
|
||||||
status: active (default) or archived.
|
status: one of active (default), paused, completed, archived.
|
||||||
color: Optional hex colour for the project card (e.g. "#6366f1").
|
color: Optional hex colour for the project card (e.g. "#6366f1").
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
@@ -101,7 +172,7 @@ async def update_project(
|
|||||||
title: New title, or omit to leave unchanged.
|
title: New title, or omit to leave unchanged.
|
||||||
description: New description, or omit to leave unchanged.
|
description: New description, or omit to leave unchanged.
|
||||||
goal: New goal/definition-of-done, or omit to leave unchanged.
|
goal: New goal/definition-of-done, or omit to leave unchanged.
|
||||||
status: New status — active or archived.
|
status: New status — one of active, paused, completed, archived.
|
||||||
color: New hex colour, or omit to leave unchanged.
|
color: New hex colour, or omit to leave unchanged.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
@@ -136,6 +207,7 @@ async def delete_project(project_id: int) -> dict:
|
|||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
for fn in (
|
for fn in (
|
||||||
list_projects,
|
list_projects,
|
||||||
|
enter_project,
|
||||||
get_project,
|
get_project,
|
||||||
create_project,
|
create_project,
|
||||||
update_project,
|
update_project,
|
||||||
@@ -13,11 +13,11 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from fabledassistant.models import async_session
|
from scribe.models import async_session
|
||||||
from fabledassistant.models.event import Event
|
from scribe.models.event import Event
|
||||||
from fabledassistant.models.note import Note
|
from scribe.models.note import Note
|
||||||
from fabledassistant.models.project import Project
|
from scribe.models.project import Project
|
||||||
|
|
||||||
|
|
||||||
async def get_recent(days: int = 7, limit: int = 25) -> dict:
|
async def get_recent(days: int = 7, limit: int = 25) -> dict:
|
||||||
@@ -38,7 +38,8 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
|
|||||||
items: list[dict] = []
|
items: list[dict] = []
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
notes = (await session.execute(
|
notes = (await session.execute(
|
||||||
select(Note).where(Note.user_id == uid, Note.updated_at >= since)
|
select(Note).where(Note.user_id == uid, Note.updated_at >= since,
|
||||||
|
Note.deleted_at.is_(None))
|
||||||
.order_by(Note.updated_at.desc()).limit(limit)
|
.order_by(Note.updated_at.desc()).limit(limit)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
for n in notes:
|
for n in notes:
|
||||||
@@ -50,7 +51,8 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
|
|||||||
})
|
})
|
||||||
projects = (await session.execute(
|
projects = (await session.execute(
|
||||||
select(Project).where(Project.user_id == uid,
|
select(Project).where(Project.user_id == uid,
|
||||||
Project.updated_at >= since)
|
Project.updated_at >= since,
|
||||||
|
Project.deleted_at.is_(None))
|
||||||
.order_by(Project.updated_at.desc()).limit(limit)
|
.order_by(Project.updated_at.desc()).limit(limit)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
for p in projects:
|
for p in projects:
|
||||||
@@ -62,7 +64,8 @@ async def get_recent(days: int = 7, limit: int = 25) -> dict:
|
|||||||
})
|
})
|
||||||
events = (await session.execute(
|
events = (await session.execute(
|
||||||
select(Event).where(Event.user_id == uid,
|
select(Event).where(Event.user_id == uid,
|
||||||
Event.updated_at >= since)
|
Event.updated_at >= since,
|
||||||
|
Event.deleted_at.is_(None))
|
||||||
.order_by(Event.updated_at.desc()).limit(limit)
|
.order_by(Event.updated_at.desc()).limit(limit)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
for e in events:
|
for e in events:
|
||||||
@@ -9,9 +9,9 @@ spec.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from fabledassistant.services import rulebooks as rulebooks_svc
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
from fabledassistant.services import trash as trash_svc
|
from scribe.services import trash as trash_svc
|
||||||
|
|
||||||
|
|
||||||
# ── Rulebook CRUD ───────────────────────────────────────────────────────
|
# ── Rulebook CRUD ───────────────────────────────────────────────────────
|
||||||
@@ -54,14 +54,26 @@ async def create_rulebook(title: str, description: str = "") -> dict:
|
|||||||
|
|
||||||
async def update_rulebook(
|
async def update_rulebook(
|
||||||
rulebook_id: int, title: str = "", description: str = "",
|
rulebook_id: int, title: str = "", description: str = "",
|
||||||
|
always_on: bool | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update an existing rulebook. Only non-empty fields are changed."""
|
"""Update an existing rulebook. Only non-empty fields are changed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rulebook_id: Rulebook to update.
|
||||||
|
title: New title. Empty string leaves unchanged.
|
||||||
|
description: New description. Empty string leaves unchanged.
|
||||||
|
always_on: When True, rules in this rulebook are loaded at session
|
||||||
|
start by list_always_on_rules regardless of project context.
|
||||||
|
Pass None to leave unchanged.
|
||||||
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
fields: dict = {}
|
fields: dict = {}
|
||||||
if title:
|
if title:
|
||||||
fields["title"] = title
|
fields["title"] = title
|
||||||
if description:
|
if description:
|
||||||
fields["description"] = description
|
fields["description"] = description
|
||||||
|
if always_on is not None:
|
||||||
|
fields["always_on"] = always_on
|
||||||
rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
|
rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
|
||||||
if rb is None:
|
if rb is None:
|
||||||
raise ValueError(f"rulebook {rulebook_id} not found")
|
raise ValueError(f"rulebook {rulebook_id} not found")
|
||||||
@@ -200,6 +212,28 @@ async def list_rules(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_always_on_rules() -> dict:
|
||||||
|
"""Return all rules from rulebooks flagged always_on for the current user.
|
||||||
|
|
||||||
|
Call this at session start. Treat the returned rules as binding for the
|
||||||
|
session — they apply regardless of which project (if any) is in scope.
|
||||||
|
Pair with get_project(id).applicable_rules when working on a specific
|
||||||
|
project to also load that project's subscription-derived rules.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
rules = await rulebooks_svc.list_always_on_rules(uid)
|
||||||
|
return {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": r.id, "title": r.title, "statement": r.statement,
|
||||||
|
"topic_id": r.topic_id,
|
||||||
|
}
|
||||||
|
for r in rules
|
||||||
|
],
|
||||||
|
"total": len(rules),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_rule(rule_id: int) -> dict:
|
async def get_rule(rule_id: int) -> dict:
|
||||||
"""Fetch a rule by id — full statement + why + how_to_apply."""
|
"""Fetch a rule by id — full statement + why + how_to_apply."""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
@@ -213,7 +247,7 @@ async def create_rule(
|
|||||||
topic_id: int, title: str, statement: str,
|
topic_id: int, title: str, statement: str,
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new rule under a topic.
|
"""Create a new rule under a topic (cross-project rulebook rule).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
topic_id: The topic to attach the rule to.
|
topic_id: The topic to attach the rule to.
|
||||||
@@ -222,6 +256,9 @@ async def create_rule(
|
|||||||
why: Optional rationale — the reason the rule exists.
|
why: Optional rationale — the reason the rule exists.
|
||||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||||
order_index: Display order within the topic (default 0).
|
order_index: Display order within the topic (default 0).
|
||||||
|
|
||||||
|
For a rule that applies to a single project only, use create_project_rule
|
||||||
|
instead — no rulebook+topic ceremony required.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
rule = await rulebooks_svc.create_rule(
|
rule = await rulebooks_svc.create_rule(
|
||||||
@@ -232,6 +269,35 @@ async def create_rule(
|
|||||||
return rule.to_dict()
|
return rule.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_project_rule(
|
||||||
|
project_id: int, statement: str, title: str = "",
|
||||||
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a rule scoped to a single project (no rulebook needed).
|
||||||
|
|
||||||
|
Use this when a rule only applies to one project — it bypasses the
|
||||||
|
Rulebook -> Topic -> Rule ceremony. The rule is returned in get_project's
|
||||||
|
applicable_rules (under project_rules) and in list_rules(project_id=...).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: The project to attach the rule to.
|
||||||
|
statement: The actionable instruction (required). 1-2 sentences.
|
||||||
|
title: Short imperative title. If empty, derived from the first ~50
|
||||||
|
characters of statement.
|
||||||
|
why: Optional rationale — the reason the rule exists.
|
||||||
|
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||||
|
order_index: Display order within the project's rule list (default 0).
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
derived_title = title.strip() or statement.strip().split(".")[0][:50]
|
||||||
|
rule = await rulebooks_svc.create_project_rule(
|
||||||
|
project_id=project_id, user_id=uid,
|
||||||
|
title=derived_title, statement=statement,
|
||||||
|
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||||
|
)
|
||||||
|
return rule.to_dict()
|
||||||
|
|
||||||
|
|
||||||
async def update_rule(
|
async def update_rule(
|
||||||
rule_id: int, title: str = "", statement: str = "",
|
rule_id: int, title: str = "", statement: str = "",
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
||||||
@@ -256,7 +322,7 @@ async def update_rule(
|
|||||||
|
|
||||||
|
|
||||||
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
|
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
|
||||||
"""Permanently delete a rule. Requires confirmed=True."""
|
"""Move a rule to the trash (recoverable). Requires confirmed=True."""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
||||||
if rule is None:
|
if rule is None:
|
||||||
@@ -264,8 +330,8 @@ async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
|
|||||||
if not confirmed:
|
if not confirmed:
|
||||||
return {
|
return {
|
||||||
"warning": (
|
"warning": (
|
||||||
f"Rule {rule_id} ('{rule.title}') will be permanently deleted. "
|
f"Rule {rule_id} ('{rule.title}') will be moved to the trash "
|
||||||
f"Pass confirmed=True to proceed."
|
f"(recoverable via restore). Pass confirmed=True to proceed."
|
||||||
),
|
),
|
||||||
"confirmed_required": True,
|
"confirmed_required": True,
|
||||||
}
|
}
|
||||||
@@ -298,11 +364,70 @@ async def unsubscribe_project_from_rulebook(
|
|||||||
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False}
|
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Suppressions — project-level mute of rulebook rules / topics ────────
|
||||||
|
|
||||||
|
async def suppress_rule_for_project(
|
||||||
|
project_id: int, rule_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Mute a single rulebook rule for one project.
|
||||||
|
|
||||||
|
The rule stays in its rulebook for other projects; only this project
|
||||||
|
skips it. Idempotent. Use unsuppress_rule_for_project to re-enable.
|
||||||
|
Project-scoped rules (create_project_rule) are NOT suppressible — delete
|
||||||
|
them with delete_rule instead.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
await rulebooks_svc.suppress_rule_for_project(
|
||||||
|
project_id=project_id, rule_id=rule_id, user_id=uid,
|
||||||
|
)
|
||||||
|
return {"project_id": project_id, "rule_id": rule_id, "suppressed": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def unsuppress_rule_for_project(
|
||||||
|
project_id: int, rule_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Re-enable a previously-suppressed rule for one project. Idempotent."""
|
||||||
|
uid = current_user_id()
|
||||||
|
await rulebooks_svc.unsuppress_rule_for_project(
|
||||||
|
project_id=project_id, rule_id=rule_id, user_id=uid,
|
||||||
|
)
|
||||||
|
return {"project_id": project_id, "rule_id": rule_id, "suppressed": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def suppress_topic_for_project(
|
||||||
|
project_id: int, topic_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Mute every rule under a topic for one project.
|
||||||
|
|
||||||
|
Equivalent to suppressing each rule in the topic individually, but
|
||||||
|
auto-includes new rules added to the topic later. Idempotent.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
await rulebooks_svc.suppress_topic_for_project(
|
||||||
|
project_id=project_id, topic_id=topic_id, user_id=uid,
|
||||||
|
)
|
||||||
|
return {"project_id": project_id, "topic_id": topic_id, "suppressed": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def unsuppress_topic_for_project(
|
||||||
|
project_id: int, topic_id: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Re-enable a previously-suppressed topic for one project. Idempotent."""
|
||||||
|
uid = current_user_id()
|
||||||
|
await rulebooks_svc.unsuppress_topic_for_project(
|
||||||
|
project_id=project_id, topic_id=topic_id, user_id=uid,
|
||||||
|
)
|
||||||
|
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
|
||||||
|
|
||||||
|
|
||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
for fn in (
|
for fn in (
|
||||||
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
||||||
list_topics, create_topic, update_topic, delete_topic,
|
list_topics, create_topic, update_topic, delete_topic,
|
||||||
list_rules, get_rule, create_rule, update_rule, delete_rule,
|
list_rules, list_always_on_rules, get_rule,
|
||||||
|
create_rule, create_project_rule, update_rule, delete_rule,
|
||||||
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
||||||
|
suppress_rule_for_project, unsuppress_rule_for_project,
|
||||||
|
suppress_topic_for_project, unsuppress_topic_for_project,
|
||||||
):
|
):
|
||||||
mcp.tool(name=fn.__name__)(fn)
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
@@ -7,17 +7,34 @@ working. Differences from fable-mcp:
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from fabledassistant.services.embeddings import semantic_search_notes
|
from scribe.services.embeddings import semantic_search_notes
|
||||||
|
|
||||||
|
|
||||||
async def search(q: str, content_type: str = "all", limit: int = 10) -> dict:
|
async def search(
|
||||||
"""Semantic search over the user's notes and tasks.
|
q: str,
|
||||||
|
content_type: str = "all",
|
||||||
|
limit: int = 10,
|
||||||
|
project_id: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""Semantic search over the user's existing notes and tasks — Scribe's recall.
|
||||||
|
|
||||||
|
Reach for this BEFORE answering a question about the user's work or starting
|
||||||
|
a task: the user's second-brain almost always already holds related prior
|
||||||
|
art. Check for an existing ticket before opening a new one (search with
|
||||||
|
content_type='task'), and for prior notes/decisions before re-deriving them.
|
||||||
|
Treating Scribe as the first place to look — not a place to only write — is
|
||||||
|
the difference between it being a trustworthy record and a write-only log.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
q: search query string.
|
q: search query string.
|
||||||
content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only).
|
content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only).
|
||||||
limit: maximum number of results (1-50).
|
limit: maximum number of results (1-50).
|
||||||
|
project_id: Scope results to one project. PASS THE ACTIVE PROJECT'S ID
|
||||||
|
whenever a project is in scope (the one you entered with
|
||||||
|
enter_project) — otherwise this searches across ALL projects and
|
||||||
|
bleeds unrelated work into the result set. 0 = search everything
|
||||||
|
(use only when you genuinely want a cross-project sweep).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
|
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
|
||||||
@@ -28,6 +45,7 @@ async def search(q: str, content_type: str = "all", limit: int = 10) -> dict:
|
|||||||
is_task = {"note": False, "task": True}.get(content_type) # None => any
|
is_task = {"note": False, "task": True}.get(content_type) # None => any
|
||||||
raw = await semantic_search_notes(
|
raw = await semantic_search_notes(
|
||||||
uid, q, limit=limit, is_task=is_task,
|
uid, q, limit=limit, is_task=is_task,
|
||||||
|
project_id=project_id or None,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"results": [
|
"results": [
|
||||||
@@ -8,9 +8,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from fabledassistant.models import async_session
|
from scribe.models import async_session
|
||||||
from fabledassistant.models.note import Note
|
from scribe.models.note import Note
|
||||||
|
|
||||||
|
|
||||||
def _aggregate_tag_counts(tag_lists) -> dict[str, int]:
|
def _aggregate_tag_counts(tag_lists) -> dict[str, int]:
|
||||||
@@ -39,7 +39,7 @@ async def list_tags(limit: int = 50) -> dict:
|
|||||||
limit = max(1, min(limit, 200))
|
limit = max(1, min(limit, 200))
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Note.tags).where(Note.user_id == uid)
|
select(Note.tags).where(Note.user_id == uid, Note.deleted_at.is_(None))
|
||||||
)
|
)
|
||||||
tag_lists = [row[0] for row in result.all()]
|
tag_lists = [row[0] for row in result.all()]
|
||||||
counts = _aggregate_tag_counts(tag_lists)
|
counts = _aggregate_tag_counts(tag_lists)
|
||||||
@@ -14,16 +14,16 @@ Sentinels (preserved from existing fable-mcp):
|
|||||||
what makes a Note a Task)
|
what makes a Note a Task)
|
||||||
- priority="none" sets explicit no-priority; priority="" is "leave unchanged"
|
- priority="none" sets explicit no-priority; priority="" is "leave unchanged"
|
||||||
- project_id=0 / milestone_id=0 / parent_id=0 → "no association" on create,
|
- project_id=0 / milestone_id=0 / parent_id=0 → "no association" on create,
|
||||||
"leave unchanged" on update
|
"leave unchanged" on update; on update, -1 clears the FK (sets it NULL)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from fabledassistant.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from fabledassistant.services import planning as planning_svc
|
from scribe.services import planning as planning_svc
|
||||||
from fabledassistant.services import rulebooks as rulebooks_svc
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
from fabledassistant.services import task_logs as task_logs_svc
|
from scribe.services import task_logs as task_logs_svc
|
||||||
from fabledassistant.services import trash as trash_svc
|
from scribe.services import trash as trash_svc
|
||||||
|
|
||||||
|
|
||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
@@ -37,7 +37,10 @@ async def list_tasks(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
|
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
|
||||||
project_id: Filter to a specific project. Use 0 for no filter.
|
project_id: Filter to a specific project. PASS THE ACTIVE PROJECT'S ID
|
||||||
|
whenever a project is in scope so you list that project's tasks, not
|
||||||
|
every project's. 0 = no filter (all projects — use only for a
|
||||||
|
deliberate cross-project view).
|
||||||
kind: Filter by task kind — 'work' or 'plan'. Omit (empty) for all kinds.
|
kind: Filter by task kind — 'work' or 'plan'. Omit (empty) for all kinds.
|
||||||
|
|
||||||
Results are ordered by last-updated descending.
|
Results are ordered by last-updated descending.
|
||||||
@@ -82,6 +85,9 @@ async def get_task(task_id: int) -> dict:
|
|||||||
data["applicable_rules"] = applicable["rules"]
|
data["applicable_rules"] = applicable["rules"]
|
||||||
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
|
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
|
||||||
data["applicable_rules_truncated"] = applicable["truncated"]
|
data["applicable_rules_truncated"] = applicable["truncated"]
|
||||||
|
data["project_rules"] = applicable.get("project_rules", [])
|
||||||
|
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
|
||||||
|
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -140,10 +146,14 @@ async def update_task(
|
|||||||
task_id: ID of the task to update.
|
task_id: ID of the task to update.
|
||||||
title: New title, or omit to leave unchanged.
|
title: New title, or omit to leave unchanged.
|
||||||
body: New markdown body, or omit to leave unchanged.
|
body: New markdown body, or omit to leave unchanged.
|
||||||
status: New status — one of: todo, in_progress, done, cancelled.
|
status: New status — one of: todo, in_progress, done, cancelled. Drive
|
||||||
|
the lifecycle: set in_progress when you start, done when complete —
|
||||||
|
don't leave finished work at todo.
|
||||||
priority: New priority — one of: none, low, medium, high.
|
priority: New priority — one of: none, low, medium, high.
|
||||||
project_id: New project. Omit (0) to leave unchanged.
|
project_id: New project. 0 = leave unchanged, -1 = clear (remove from
|
||||||
milestone_id: New milestone. Omit (0) to leave unchanged.
|
its project; also clears the milestone), positive = set.
|
||||||
|
milestone_id: New milestone. 0 = leave unchanged, -1 = clear (remove
|
||||||
|
from its milestone), positive = set.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
fields: dict = {}
|
fields: dict = {}
|
||||||
@@ -155,9 +165,15 @@ async def update_task(
|
|||||||
fields["status"] = status
|
fields["status"] = status
|
||||||
if priority:
|
if priority:
|
||||||
fields["priority"] = priority
|
fields["priority"] = priority
|
||||||
if project_id:
|
# Optional FKs: 0 = leave unchanged, -1 = clear (set NULL), positive = set.
|
||||||
|
if project_id == -1:
|
||||||
|
fields["project_id"] = None
|
||||||
|
fields["milestone_id"] = None # a milestone can't outlive its project
|
||||||
|
elif project_id:
|
||||||
fields["project_id"] = project_id
|
fields["project_id"] = project_id
|
||||||
if milestone_id:
|
if milestone_id == -1:
|
||||||
|
fields["milestone_id"] = None
|
||||||
|
elif milestone_id:
|
||||||
fields["milestone_id"] = milestone_id
|
fields["milestone_id"] = milestone_id
|
||||||
note = await notes_svc.update_note(uid, task_id, **fields)
|
note = await notes_svc.update_note(uid, task_id, **fields)
|
||||||
if note is None:
|
if note is None:
|
||||||
@@ -6,8 +6,8 @@ the deleted_batch_id that a delete operation returns.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from fabledassistant.services import trash as trash_svc
|
from scribe.services import trash as trash_svc
|
||||||
|
|
||||||
|
|
||||||
async def list_trash() -> dict:
|
async def list_trash() -> dict:
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from scribe.config import Config
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
Config.DATABASE_URL,
|
||||||
|
echo=False,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=1800,
|
||||||
|
)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
from scribe.models.base import CreatedAtMixin, TimestampMixin # noqa: E402, F401
|
||||||
|
|
||||||
|
|
||||||
|
from scribe.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
|
||||||
|
from scribe.models.setting import Setting # noqa: E402, F401
|
||||||
|
from scribe.models.user import User # noqa: E402, F401
|
||||||
|
from scribe.models.app_log import AppLog # noqa: E402, F401
|
||||||
|
from scribe.models.password_reset import PasswordResetToken # noqa: E402, F401
|
||||||
|
from scribe.models.invitation import InvitationToken # noqa: E402, F401
|
||||||
|
from scribe.models.embedding import NoteEmbedding # noqa: E402, F401
|
||||||
|
from scribe.models.project import Project # noqa: E402, F401
|
||||||
|
from scribe.models.event import Event # noqa: E402, F401
|
||||||
|
from scribe.models.milestone import Milestone # noqa: E402, F401
|
||||||
|
from scribe.models.task_log import TaskLog # noqa: E402, F401
|
||||||
|
from scribe.models.note_draft import NoteDraft # noqa: E402, F401
|
||||||
|
from scribe.models.note_version import NoteVersion # noqa: E402, F401
|
||||||
|
from scribe.models.group import Group, GroupMembership # noqa: E402, F401
|
||||||
|
from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
|
||||||
|
from scribe.models.notification import Notification # noqa: E402, F401
|
||||||
|
from scribe.models.api_key import ApiKey # noqa: E402, F401
|
||||||
|
from scribe.models.user_profile import UserProfile # noqa: E402, F401
|
||||||
|
from scribe.models.rulebook import ( # noqa: E402, F401
|
||||||
|
Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
|
||||||
|
)
|
||||||
@@ -3,8 +3,8 @@ from datetime import datetime
|
|||||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import CreatedAtMixin
|
from scribe.models.base import CreatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
class ApiKey(Base, CreatedAtMixin):
|
class ApiKey(Base, CreatedAtMixin):
|
||||||
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy import DateTime, Float, Index, Integer, Text
|
from sqlalchemy import DateTime, Float, Index, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
|
|
||||||
|
|
||||||
class AppLog(Base):
|
class AppLog(Base):
|
||||||
@@ -4,7 +4,7 @@ from sqlalchemy import DateTime, ForeignKey, Integer
|
|||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
|
|
||||||
|
|
||||||
class NoteEmbedding(Base):
|
class NoteEmbedding(Base):
|
||||||
@@ -3,8 +3,8 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import SoftDeleteMixin
|
from scribe.models.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class Event(Base, SoftDeleteMixin):
|
class Event(Base, SoftDeleteMixin):
|
||||||
@@ -3,8 +3,8 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
|
from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin
|
from scribe.models.base import CreatedAtMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class Group(Base, TimestampMixin):
|
class Group(Base, TimestampMixin):
|
||||||
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
|
|
||||||
|
|
||||||
class InvitationToken(Base):
|
class InvitationToken(Base):
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from sqlalchemy import ForeignKey, Integer, Text
|
from sqlalchemy import ForeignKey, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin
|
from scribe.models.base import TimestampMixin, SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class Milestone(Base, TimestampMixin, SoftDeleteMixin):
|
class Milestone(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
@@ -5,8 +5,8 @@ from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
|
|||||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin
|
from scribe.models.base import TimestampMixin, SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class TaskStatus(str, enum.Enum):
|
class TaskStatus(str, enum.Enum):
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from sqlalchemy import ForeignKey, Integer, Text
|
from sqlalchemy import ForeignKey, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import TimestampMixin
|
from scribe.models.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class NoteDraft(Base, TimestampMixin):
|
class NoteDraft(Base, TimestampMixin):
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from sqlalchemy import ARRAY, ForeignKey, Integer, Text
|
from sqlalchemy import ARRAY, ForeignKey, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import CreatedAtMixin
|
from scribe.models.base import CreatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
class NoteVersion(Base, CreatedAtMixin):
|
class NoteVersion(Base, CreatedAtMixin):
|
||||||
@@ -4,8 +4,8 @@ from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
|||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import CreatedAtMixin
|
from scribe.models.base import CreatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
class Notification(Base, CreatedAtMixin):
|
class Notification(Base, CreatedAtMixin):
|
||||||
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetToken(Base):
|
class PasswordResetToken(Base):
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import enum
|
import enum
|
||||||
from datetime import datetime
|
from sqlalchemy import ForeignKey, Integer, Text
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import TimestampMixin, SoftDeleteMixin
|
from scribe.models.base import TimestampMixin, SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class ProjectStatus(str, enum.Enum):
|
class ProjectStatus(str, enum.Enum):
|
||||||
active = "active"
|
active = "active"
|
||||||
|
paused = "paused"
|
||||||
completed = "completed"
|
completed = "completed"
|
||||||
archived = "archived"
|
archived = "archived"
|
||||||
|
|
||||||
@@ -21,10 +21,6 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
goal: Mapped[str] = mapped_column(Text, default="")
|
goal: Mapped[str] = mapped_column(Text, default="")
|
||||||
status: Mapped[str] = mapped_column(Text, default="active")
|
status: Mapped[str] = mapped_column(Text, default="active")
|
||||||
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
|
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
|
||||||
auto_summary: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
|
||||||
summary_updated_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True, default=None
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -35,7 +31,6 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"goal": self.goal,
|
"goal": self.goal,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
"auto_summary": self.auto_summary,
|
|
||||||
"created_at": self.created_at.isoformat(),
|
"created_at": self.created_at.isoformat(),
|
||||||
"updated_at": self.updated_at.isoformat(),
|
"updated_at": self.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, Table, Text, UniqueConstraint
|
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, Text, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import SoftDeleteMixin
|
from scribe.models.base import SoftDeleteMixin
|
||||||
|
|
||||||
|
|
||||||
class Rulebook(Base, SoftDeleteMixin):
|
class Rulebook(Base, SoftDeleteMixin):
|
||||||
@@ -16,6 +16,9 @@ class Rulebook(Base, SoftDeleteMixin):
|
|||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(Text)
|
title: Mapped[str] = mapped_column(Text)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
always_on: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, nullable=False, server_default="false"
|
||||||
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
@@ -31,6 +34,7 @@ class Rulebook(Base, SoftDeleteMixin):
|
|||||||
"owner_user_id": self.owner_user_id,
|
"owner_user_id": self.owner_user_id,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"description": self.description or "",
|
"description": self.description or "",
|
||||||
|
"always_on": self.always_on,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
@@ -38,8 +42,13 @@ class Rulebook(Base, SoftDeleteMixin):
|
|||||||
|
|
||||||
class RulebookTopic(Base, SoftDeleteMixin):
|
class RulebookTopic(Base, SoftDeleteMixin):
|
||||||
__tablename__ = "rulebook_topics"
|
__tablename__ = "rulebook_topics"
|
||||||
|
# Partial unique: a title is unique among LIVE topics in a rulebook, so a
|
||||||
|
# trashed topic doesn't block recreating/restoring the same title.
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("rulebook_id", "title", name="uq_topic_per_rulebook"),
|
Index(
|
||||||
|
"uq_topic_per_rulebook", "rulebook_id", "title",
|
||||||
|
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
@@ -72,13 +81,28 @@ class RulebookTopic(Base, SoftDeleteMixin):
|
|||||||
|
|
||||||
class Rule(Base, SoftDeleteMixin):
|
class Rule(Base, SoftDeleteMixin):
|
||||||
__tablename__ = "rules"
|
__tablename__ = "rules"
|
||||||
|
# Partial unique: title unique among LIVE rules in a topic (soft-deleted
|
||||||
|
# rules don't block recreating/restoring the same title).
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("topic_id", "title", name="uq_rule_per_topic"),
|
Index(
|
||||||
|
"uq_rule_per_topic", "topic_id", "title",
|
||||||
|
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
topic_id: Mapped[int] = mapped_column(
|
# Exactly one of topic_id / project_id is set — enforced by CHECK
|
||||||
BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE")
|
# constraint ck_rule_topic_xor_project (migration 0059).
|
||||||
|
topic_id: Mapped[int | None] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
project_id: Mapped[int | None] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(Text)
|
title: Mapped[str] = mapped_column(Text)
|
||||||
statement: Mapped[str] = mapped_column(Text)
|
statement: Mapped[str] = mapped_column(Text)
|
||||||
@@ -98,6 +122,7 @@ class Rule(Base, SoftDeleteMixin):
|
|||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"topic_id": self.topic_id,
|
"topic_id": self.topic_id,
|
||||||
|
"project_id": self.project_id,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"statement": self.statement,
|
"statement": self.statement,
|
||||||
"why": self.why or "",
|
"why": self.why or "",
|
||||||
@@ -116,3 +141,22 @@ project_rulebook_subscriptions = Table(
|
|||||||
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
|
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
|
||||||
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Suppressions — let a project mute individual rules or whole topics from
|
||||||
|
# rulebooks it subscribes to, without unsubscribing the rulebook itself.
|
||||||
|
# FKs CASCADE so the row vanishes when its parent is removed.
|
||||||
|
project_rule_suppressions = Table(
|
||||||
|
"project_rule_suppressions",
|
||||||
|
Base.metadata,
|
||||||
|
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||||
|
)
|
||||||
|
|
||||||
|
project_topic_suppressions = Table(
|
||||||
|
"project_topic_suppressions",
|
||||||
|
Base.metadata,
|
||||||
|
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("topic_id", BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||||
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from sqlalchemy import ForeignKey, Integer, Text
|
from sqlalchemy import ForeignKey, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
|
|
||||||
|
|
||||||
class Setting(Base):
|
class Setting(Base):
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text
|
from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import TimestampMixin
|
from scribe.models.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class ProjectShare(Base, TimestampMixin):
|
class ProjectShare(Base, TimestampMixin):
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from sqlalchemy import ForeignKey, Integer, Text
|
from sqlalchemy import ForeignKey, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import TimestampMixin
|
from scribe.models.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class TaskLog(Base, TimestampMixin):
|
class TaskLog(Base, TimestampMixin):
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from sqlalchemy import Index, Integer, Text
|
from sqlalchemy import Index, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import CreatedAtMixin
|
from scribe.models.base import CreatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
class User(Base, CreatedAtMixin):
|
class User(Base, CreatedAtMixin):
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
from datetime import datetime
|
from sqlalchemy import ForeignKey, Integer, Text
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
|
||||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from scribe.models import Base
|
||||||
from fabledassistant.models.base import TimestampMixin
|
from scribe.models.base import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(Base, TimestampMixin):
|
class UserProfile(Base, TimestampMixin):
|
||||||
@@ -27,13 +25,6 @@ class UserProfile(Base, TimestampMixin):
|
|||||||
interests: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
interests: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||||
# {days: ["Mon","Tue",...], start: "09:00", end: "17:00"}
|
# {days: ["Mon","Tue",...], start: "09:00", end: "17:00"}
|
||||||
work_schedule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
work_schedule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
# LLM-consolidated summary of learned preferences
|
|
||||||
learned_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
# [{date: "YYYY-MM-DD", bullets: "..."}, ...]
|
|
||||||
observations_raw: Mapped[list | None] = mapped_column(JSONB, nullable=True)
|
|
||||||
observations_updated_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -45,11 +36,4 @@ class UserProfile(Base, TimestampMixin):
|
|||||||
"tone": self.tone or "casual",
|
"tone": self.tone or "casual",
|
||||||
"interests": self.interests or [],
|
"interests": self.interests or [],
|
||||||
"work_schedule": self.work_schedule or {},
|
"work_schedule": self.work_schedule or {},
|
||||||
"learned_summary": self.learned_summary or "",
|
|
||||||
"observations_count": len(self.observations_raw or []),
|
|
||||||
"observations_updated_at": (
|
|
||||||
self.observations_updated_at.isoformat()
|
|
||||||
if self.observations_updated_at
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@ import json
|
|||||||
|
|
||||||
from quart import Blueprint, Response, g, jsonify, request
|
from quart import Blueprint, Response, g, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import admin_required, login_required, get_current_user_id
|
from scribe.auth import admin_required, login_required, get_current_user_id
|
||||||
from fabledassistant.services.auth import (
|
from scribe.services.auth import (
|
||||||
create_invitation,
|
create_invitation,
|
||||||
delete_user,
|
delete_user,
|
||||||
is_registration_open,
|
is_registration_open,
|
||||||
@@ -13,15 +13,15 @@ from fabledassistant.services.auth import (
|
|||||||
revoke_invitation,
|
revoke_invitation,
|
||||||
set_registration_open,
|
set_registration_open,
|
||||||
)
|
)
|
||||||
from fabledassistant.services.backup import (
|
from scribe.services.backup import (
|
||||||
export_full_backup,
|
export_full_backup,
|
||||||
export_user_backup,
|
export_user_backup,
|
||||||
restore_full_backup,
|
restore_full_backup,
|
||||||
)
|
)
|
||||||
from fabledassistant.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
|
from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
|
||||||
from fabledassistant.services.logging import get_logs, get_log_stats, log_audit
|
from scribe.services.logging import get_logs, get_log_stats, log_audit
|
||||||
from fabledassistant.services.notifications import send_invitation_email
|
from scribe.services.notifications import send_invitation_email
|
||||||
from fabledassistant.services.settings import set_setting, set_settings_batch
|
from scribe.services.settings import set_setting, set_settings_batch
|
||||||
|
|
||||||
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
|
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from scribe.auth import login_required, get_current_user_id
|
||||||
from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key
|
from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key
|
||||||
|
|
||||||
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
|
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user