From 940a703cd75770051cd62873be2cb6b1a24350bb Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 1 Sep 2026 21:55:02 +0100 Subject: [PATCH] Initial public commit Daybook: a self-hosted, multi-user Django + DRF app where agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed. This squashes prior local development history (kept privately on a separate branch) into a single clean starting point for the public repository. Co-Authored-By: Claude Sonnet 5 --- .claude/commands/seed-demo.md | 13 + .dockerignore | 19 + .gitignore | 40 + CLAUDE.md | 75 ++ artifacts/__init__.py | 0 artifacts/admin.py | 23 + artifacts/apps.py | 6 + artifacts/demo_data.py | 87 ++ artifacts/forms.py | 6 + artifacts/migrations/0001_initial.py | 27 + ...ct_metadata_artifact_source_id_and_more.py | 41 + artifacts/migrations/0003_tagcolor.py | 20 + .../0004_rename_persona_to_source.py | 16 + .../migrations/0005_add_owner_nullable.py | 24 + artifacts/migrations/0006_backfill_owner.py | 27 + artifacts/migrations/0007_owner_required.py | 23 + artifacts/migrations/0008_artifact_read_at.py | 18 + artifacts/migrations/0009_quota_and_keep.py | 34 + .../0010_backfill_sizes_and_profiles.py | 43 + artifacts/migrations/0011_add_asset.py | 31 + artifacts/migrations/__init__.py | 0 artifacts/models.py | 105 ++ artifacts/storage.py | 135 +++ artifacts/urls.py | 21 + artifacts/views.py | 903 ++++++++++++++++ daybook_project/__init__.py | 0 daybook_project/asgi.py | 8 + daybook_project/settings.py | 82 ++ daybook_project/urls.py | 13 + daybook_project/wsgi.py | 8 + deploy/Dockerfile | 24 + deploy/docker-compose.yml | 19 + deploy/entrypoint.sh | 9 + deploy/env.example | 7 + deploy/proxmox_llm_deploy.md | 348 +++++++ docs/agent_quickstart.md | 140 +++ docs/api.md | 425 ++++++++ docs/llm_posting_helper.md | 108 ++ manage.py | 14 + requirements.txt | 5 + scripts/README.md | 28 + scripts/daybook_post.py | 16 + scripts/seed_demo_artifacts.py | 111 ++ skills/daybook-posting/SKILL.md | 73 ++ .../daybook-posting/scripts/daybook_post.py | 429 ++++++++ static/daybook.css | 965 ++++++++++++++++++ static/themes/base.css | 18 + static/themes/deck.css | 30 + static/themes/report.css | 41 + static/themes/tasks.css | 20 + templates/artifacts/_day_groups.html | 42 + templates/artifacts/_feed_toolbar.html | 28 + templates/artifacts/_topbar.html | 37 + templates/artifacts/feed.html | 567 ++++++++++ templates/artifacts/login.html | 34 + templates/artifacts/settings_account.html | 15 + templates/artifacts/settings_base.html | 29 + templates/artifacts/settings_demo_data.html | 10 + templates/artifacts/settings_storage.html | 39 + tests/__init__.py | 0 tests/api/__init__.py | 0 tests/api/test_artifacts.py | 276 +++++ 62 files changed, 5755 insertions(+) create mode 100644 .claude/commands/seed-demo.md create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 artifacts/__init__.py create mode 100644 artifacts/admin.py create mode 100644 artifacts/apps.py create mode 100644 artifacts/demo_data.py create mode 100644 artifacts/forms.py create mode 100644 artifacts/migrations/0001_initial.py create mode 100644 artifacts/migrations/0002_artifact_metadata_artifact_source_id_and_more.py create mode 100644 artifacts/migrations/0003_tagcolor.py create mode 100644 artifacts/migrations/0004_rename_persona_to_source.py create mode 100644 artifacts/migrations/0005_add_owner_nullable.py create mode 100644 artifacts/migrations/0006_backfill_owner.py create mode 100644 artifacts/migrations/0007_owner_required.py create mode 100644 artifacts/migrations/0008_artifact_read_at.py create mode 100644 artifacts/migrations/0009_quota_and_keep.py create mode 100644 artifacts/migrations/0010_backfill_sizes_and_profiles.py create mode 100644 artifacts/migrations/0011_add_asset.py create mode 100644 artifacts/migrations/__init__.py create mode 100644 artifacts/models.py create mode 100644 artifacts/storage.py create mode 100644 artifacts/urls.py create mode 100644 artifacts/views.py create mode 100644 daybook_project/__init__.py create mode 100644 daybook_project/asgi.py create mode 100644 daybook_project/settings.py create mode 100644 daybook_project/urls.py create mode 100644 daybook_project/wsgi.py create mode 100644 deploy/Dockerfile create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/entrypoint.sh create mode 100644 deploy/env.example create mode 100644 deploy/proxmox_llm_deploy.md create mode 100644 docs/agent_quickstart.md create mode 100644 docs/api.md create mode 100644 docs/llm_posting_helper.md create mode 100644 manage.py create mode 100644 requirements.txt create mode 100644 scripts/README.md create mode 100644 scripts/daybook_post.py create mode 100644 scripts/seed_demo_artifacts.py create mode 100644 skills/daybook-posting/SKILL.md create mode 100644 skills/daybook-posting/scripts/daybook_post.py create mode 100644 static/daybook.css create mode 100644 static/themes/base.css create mode 100644 static/themes/deck.css create mode 100644 static/themes/report.css create mode 100644 static/themes/tasks.css create mode 100644 templates/artifacts/_day_groups.html create mode 100644 templates/artifacts/_feed_toolbar.html create mode 100644 templates/artifacts/_topbar.html create mode 100644 templates/artifacts/feed.html create mode 100644 templates/artifacts/login.html create mode 100644 templates/artifacts/settings_account.html create mode 100644 templates/artifacts/settings_base.html create mode 100644 templates/artifacts/settings_demo_data.html create mode 100644 templates/artifacts/settings_storage.html create mode 100644 tests/__init__.py create mode 100644 tests/api/__init__.py create mode 100644 tests/api/test_artifacts.py diff --git a/.claude/commands/seed-demo.md b/.claude/commands/seed-demo.md new file mode 100644 index 0000000..ccca3ce --- /dev/null +++ b/.claude/commands/seed-demo.md @@ -0,0 +1,13 @@ +--- +description: Post one demo artifact of every kind (markdown, html_fragment, html_full, deck, image, pdf, binary), tagged, dated today. +--- + +Run the demo seeding script to populate today with one tagged sample artifact per supported kind: + +```bash +python scripts/seed_demo_artifacts.py +``` + +Requires the Daybook dev server to be running on the project's fixed local port: `python manage.py runserver 127.0.0.1:8001`. The script defaults to `DAYBOOK_URL=http://127.0.0.1:8001` / `DAYBOOK_TOKEN=dev-token`; no overrides needed unless the user has changed these. + +After running, report each posted artifact's kind and URL, and remind the user to reload the feed for today. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3e6ecaf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git/ +.idea/ +.venv/ +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.pytest_cache/ +.coverage +htmlcov/ +staticfiles/ +db.sqlite3 +artifacts_store/ +django-runserver.log +django-runserver.err.log +.env +.env.* +deploy/.env +deploy/data/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7417b79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python + +# Virtualenvs +.venv/ +venv/ +env/ + +# Django/local runtime +db.sqlite3 +artifacts_store/ +django-runserver.log +django-runserver.err.log + +# Static/build/cache +staticfiles/ +.coverage +htmlcov/ +.pytest_cache/ + +# Secrets/env +.env +.env.* + +# IDE/OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# Internal-only planning/ops docs (home infrastructure details) +_archive_/ +planning/ + +# Local machine-specific tool config (MCP servers, absolute local paths) +opencode.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e5041c9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +## Project + +Daybook is a self-hosted, multi-user Django + Django REST Framework web app where AI agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed. + +The core product principle is: agents publish content, not files. The server owns IDs, styling, storage layout, dates, and normalization into browser-native HTML, image, or PDF outputs. + +## Current MVP Decisions + +- Each user has a personal ingest API token (DRF `Token` model), viewable/regenerable from Settings → Account. +- Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself. +- `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either. +- Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions. +- User accounts are created/managed via Django admin (superusers only — regular users are not staff and cannot access `/admin/`). +- `TagColor` (label color overrides) stays global/shared across all users. +- Use random 8-character artifact IDs. +- Start as a plain Django project; add Docker after core ingest and viewer work. +- Preserve original markdown as `.md` alongside rendered `.html`. + +## Architecture Notes + +- Planning source: `planning/daybook_planning.md`. +- Django project: `daybook_project/`. +- Main app: `artifacts/`. +- Theme CSS: `static/themes/`. +- Feed template: `templates/artifacts/feed.html`. +- Settings templates: `templates/artifacts/settings_*.html`. +- Stored artifact files live under `artifacts_store/{source}/{YYYY-MM-DD}/`. + +## Implementation Guidelines + +- Prefer small, direct changes over broad abstractions. +- Keep ingest normalization server-side. +- The viewer should only need to render HTML, image, or PDF. +- Do not make agents responsible for styling, filenames, or storage paths. +- Preserve existing planning decisions unless the user explicitly changes them. +- Avoid adding backwards-compatibility code before anything has shipped. + +## Development And Deployment Workflow + +- Develop and test locally in this workspace by default. +- Use the local Django commands for normal iteration: `python manage.py check`, `python manage.py migrate`, and `python manage.py runserver`. +- Run the local development server on `127.0.0.1:8001`. Port `8000` may be reserved or blocked on this workstation. +- Always use this same fixed port for the dev server. Never start a second instance on a different port; if `8001` is already serving, reuse it rather than starting another. +- All artifact-posting tools (`skills/daybook-posting/scripts/daybook_post.py`, `scripts/seed_demo_artifacts.py`, and any future posting skill) must default `DAYBOOK_URL` to `http://127.0.0.1:8001` to match. +- Do not deploy to the Proxmox NUC during routine code iteration. +- Treat the NUC container as the local live/staging target. Deploy there only at explicit stable checkpoints, such as an end-of-day build. +- Before any NUC deployment, preserve `/opt/daybook/deploy/.env` and `/opt/daybook/deploy/data`. +- After any NUC deployment or configuration change, run the documented health checks and update the NUC ops knowledge base plus `planning/daybook-container-handoff.md` if takeover facts changed. + +## Repository Notes + +- `_archive_/` exists for retired or moved project docs and experiments that should not drive current implementation decisions. +- Current deployment/takeover details live in `planning/daybook-container-handoff.md`; canonical NUC infrastructure details live in the NUC ops repo. + +## Commands + +- Install dependencies: `pip install -r requirements.txt` +- Run migrations: `python manage.py migrate` +- Start dev server: `python manage.py runserver 127.0.0.1:8001` +- Local app URL: `http://127.0.0.1:8001/` +- Local API health check: `http://127.0.0.1:8001/api/capabilities` +- Django checks: `python manage.py check` + +## API Defaults + +- MVP ingest endpoint: `POST /api/artifacts` +- MVP list endpoint: `GET /api/artifacts?label=&day=&kind=&q=` +- Artifact view: `GET /a/{id}` +- Auth: personal per-user token from Settings → Account, `Authorization: Bearer `. Get one by creating a superuser (`python manage.py createsuperuser`), logging in, and visiting Settings → Account. + +## Claude Code / OpenCode + +This project uses `CLAUDE.md` for project instructions, compatible with both Claude Code and OpenCode. The `opencode.json` configuration file loads this file. After changing `opencode.json`, `.opencode/`, or this file, restart your AI coding assistant so the updated instructions are loaded. diff --git a/artifacts/__init__.py b/artifacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/admin.py b/artifacts/admin.py new file mode 100644 index 0000000..0a0d7f2 --- /dev/null +++ b/artifacts/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from .models import Artifact, Asset, UserProfile + + +@admin.register(Artifact) +class ArtifactAdmin(admin.ModelAdmin): + list_display = ("id", "title", "owner", "source", "kind", "is_kept", "size_bytes", "day", "created_at") + list_filter = ("owner", "source", "source_type", "kind", "is_kept", "day") + search_fields = ("id", "title", "source", "source_name", "source_id") + + +@admin.register(UserProfile) +class UserProfileAdmin(admin.ModelAdmin): + list_display = ("user", "quota_bytes") + search_fields = ("user__username",) + + +@admin.register(Asset) +class AssetAdmin(admin.ModelAdmin): + list_display = ("id", "owner", "artifact", "content_type", "size_bytes", "created_at") + list_filter = ("owner", "content_type") + search_fields = ("id",) diff --git a/artifacts/apps.py b/artifacts/apps.py new file mode 100644 index 0000000..b296983 --- /dev/null +++ b/artifacts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ArtifactsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "artifacts" diff --git a/artifacts/demo_data.py b/artifacts/demo_data.py new file mode 100644 index 0000000..2ac4f24 --- /dev/null +++ b/artifacts/demo_data.py @@ -0,0 +1,87 @@ +"""Sample artifact content for the Settings -> Demo Data button. + +Creates one artifact of every supported kind, owned by the requesting user, +tagged "demo", with source "demo". +""" + +SOURCE = "demo" + +TEXT_SAMPLES = [ + ( + "markdown", + "Seed: Markdown Sample", + "# Markdown Sample\n\nThis is a **seed** artifact demonstrating markdown rendering.\n\n- item one\n- item two\n", + "markdown", + ), + ( + "html_fragment", + "Seed: HTML Fragment Sample", + "

HTML Fragment

This is a seed HTML fragment artifact.

", + "html-fragment", + ), + ( + "html_full", + "Seed: Full HTML Sample", + "Seed Full HTML" + "

Full HTML Doc

Seed artifact.

", + "html-full", + ), + ( + "deck", + "Seed: Deck Sample", + "

Slide One

Seed deck artifact.

" + "

Slide Two

More content.

", + "deck", + ), +] + +SEED_PNG_BYTES = bytes.fromhex( + "89504e470d0a1a0a0000000d494844520000000100000001080600000" + "01f15c4890000000a49444154789c6360000002000155e59a1a0000000049454e44ae426082" +) + +SEED_PDF_BYTES = ( + b"%PDF-1.1\n1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"trailer<>\n" +) + +SEED_BIN_BYTES = b"\x00\x01\x02\x03seed-binary-data\xff\xfe" + +BINARY_SAMPLES = [ + ("Seed: Image Sample", "seed_pixel.png", "image/png", SEED_PNG_BYTES, "image"), + ("Seed: PDF Sample", "seed_doc.pdf", "application/pdf", SEED_PDF_BYTES, "pdf"), + ("Seed: Binary Sample", "seed_data.bin", "application/octet-stream", SEED_BIN_BYTES, "binary"), +] + + +def create_demo_artifacts(user, request): + from django.core.files.uploadedfile import SimpleUploadedFile + + from .views import create_binary_artifact, create_text_artifact + + created = 0 + for kind, title, content, tag in TEXT_SAMPLES: + data = { + "source": SOURCE, + "title": title, + "kind": kind, + "content": content, + "tags": ["demo", tag], + } + create_text_artifact(data, request, user) + created += 1 + + for title, filename, content_type, file_bytes, tag in BINARY_SAMPLES: + uploaded = SimpleUploadedFile(filename, file_bytes, content_type=content_type) + data = { + "source": SOURCE, + "title": title, + "file": uploaded, + "tags": ["demo", tag], + } + create_binary_artifact(data, user) + created += 1 + + return created diff --git a/artifacts/forms.py b/artifacts/forms.py new file mode 100644 index 0000000..44f8e98 --- /dev/null +++ b/artifacts/forms.py @@ -0,0 +1,6 @@ +from django import forms +from django.contrib.auth.forms import AuthenticationForm + + +class DaybookLoginForm(AuthenticationForm): + remember_me = forms.BooleanField(required=False, initial=True) diff --git a/artifacts/migrations/0001_initial.py b/artifacts/migrations/0001_initial.py new file mode 100644 index 0000000..e2d38dd --- /dev/null +++ b/artifacts/migrations/0001_initial.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Artifact", + fields=[ + ("id", models.CharField(max_length=8, primary_key=True, serialize=False)), + ("persona", models.CharField(db_index=True, max_length=80)), + ("title", models.CharField(max_length=200)), + ("kind", models.CharField(choices=[("html_fragment", "HTML fragment"), ("html_full", "Full HTML"), ("markdown", "Markdown"), ("deck", "Deck"), ("image", "Image"), ("pdf", "PDF"), ("binary", "Binary")], max_length=20)), + ("theme", models.CharField(blank=True, max_length=40)), + ("day", models.DateField(db_index=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("path", models.CharField(max_length=500)), + ("original_kind", models.CharField(max_length=20)), + ("tags", models.JSONField(blank=True, default=list)), + ("source_prompt", models.TextField(blank=True)), + ], + options={"ordering": ["-created_at"]}, + ), + ] diff --git a/artifacts/migrations/0002_artifact_metadata_artifact_source_id_and_more.py b/artifacts/migrations/0002_artifact_metadata_artifact_source_id_and_more.py new file mode 100644 index 0000000..5494a97 --- /dev/null +++ b/artifacts/migrations/0002_artifact_metadata_artifact_source_id_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.15 on 2026-06-10 15:14 + +from django.db import migrations, models + + +def fill_existing_source_names(apps, schema_editor): + Artifact = apps.get_model('artifacts', 'Artifact') + for artifact in Artifact.objects.filter(source_name=''): + artifact.source_name = artifact.persona + artifact.save(update_fields=['source_name']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='artifact', + name='metadata', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='artifact', + name='source_id', + field=models.CharField(blank=True, max_length=120), + ), + migrations.AddField( + model_name='artifact', + name='source_name', + field=models.CharField(blank=True, max_length=120), + ), + migrations.AddField( + model_name='artifact', + name='source_type', + field=models.CharField(choices=[('agent', 'Agent'), ('person', 'Person'), ('system', 'System')], default='agent', max_length=20), + ), + migrations.RunPython(fill_existing_source_names, migrations.RunPython.noop), + ] diff --git a/artifacts/migrations/0003_tagcolor.py b/artifacts/migrations/0003_tagcolor.py new file mode 100644 index 0000000..a0c52ac --- /dev/null +++ b/artifacts/migrations/0003_tagcolor.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.15 on 2026-07-23 06:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0002_artifact_metadata_artifact_source_id_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='TagColor', + fields=[ + ('name', models.CharField(max_length=40, primary_key=True, serialize=False)), + ('color', models.CharField(max_length=20)), + ], + ), + ] diff --git a/artifacts/migrations/0004_rename_persona_to_source.py b/artifacts/migrations/0004_rename_persona_to_source.py new file mode 100644 index 0000000..5c2480c --- /dev/null +++ b/artifacts/migrations/0004_rename_persona_to_source.py @@ -0,0 +1,16 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("artifacts", "0003_tagcolor"), + ] + + operations = [ + migrations.RenameField( + model_name="artifact", + old_name="persona", + new_name="source", + ), + ] diff --git a/artifacts/migrations/0005_add_owner_nullable.py b/artifacts/migrations/0005_add_owner_nullable.py new file mode 100644 index 0000000..f3da6dc --- /dev/null +++ b/artifacts/migrations/0005_add_owner_nullable.py @@ -0,0 +1,24 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("artifacts", "0004_rename_persona_to_source"), + ] + + operations = [ + migrations.AddField( + model_name="artifact", + name="owner", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="artifacts", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/artifacts/migrations/0006_backfill_owner.py b/artifacts/migrations/0006_backfill_owner.py new file mode 100644 index 0000000..941bab7 --- /dev/null +++ b/artifacts/migrations/0006_backfill_owner.py @@ -0,0 +1,27 @@ +from django.db import migrations + + +def backfill_owner(apps, schema_editor): + User = apps.get_model("auth", "User") + Artifact = apps.get_model("artifacts", "Artifact") + + first_superuser = User.objects.filter(is_superuser=True).order_by("date_joined").first() + if first_superuser is None: + return + + Artifact.objects.filter(owner__isnull=True).update(owner=first_superuser) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("artifacts", "0005_add_owner_nullable"), + ] + + operations = [ + migrations.RunPython(backfill_owner, noop_reverse), + ] diff --git a/artifacts/migrations/0007_owner_required.py b/artifacts/migrations/0007_owner_required.py new file mode 100644 index 0000000..f588a3f --- /dev/null +++ b/artifacts/migrations/0007_owner_required.py @@ -0,0 +1,23 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("artifacts", "0006_backfill_owner"), + ] + + operations = [ + migrations.AlterField( + model_name="artifact", + name="owner", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="artifacts", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/artifacts/migrations/0008_artifact_read_at.py b/artifacts/migrations/0008_artifact_read_at.py new file mode 100644 index 0000000..d86ddf0 --- /dev/null +++ b/artifacts/migrations/0008_artifact_read_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-07-23 21:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0007_owner_required'), + ] + + operations = [ + migrations.AddField( + model_name='artifact', + name='read_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/artifacts/migrations/0009_quota_and_keep.py b/artifacts/migrations/0009_quota_and_keep.py new file mode 100644 index 0000000..704fbf1 --- /dev/null +++ b/artifacts/migrations/0009_quota_and_keep.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.15 on 2026-07-24 07:15 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0008_artifact_read_at'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='artifact', + name='is_kept', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='artifact', + name='size_bytes', + field=models.BigIntegerField(default=0), + ), + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quota_bytes', models.BigIntegerField(default=1073741824)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/artifacts/migrations/0010_backfill_sizes_and_profiles.py b/artifacts/migrations/0010_backfill_sizes_and_profiles.py new file mode 100644 index 0000000..7766cd9 --- /dev/null +++ b/artifacts/migrations/0010_backfill_sizes_and_profiles.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from django.conf import settings +from django.db import migrations + + +def backfill(apps, schema_editor): + Artifact = apps.get_model("artifacts", "Artifact") + User = apps.get_model("auth", "User") + UserProfile = apps.get_model("artifacts", "UserProfile") + + root = Path(settings.ARTIFACTS_ROOT) + for artifact in Artifact.objects.all(): + total = 0 + primary = root / artifact.path + if primary.exists(): + total += primary.stat().st_size + if artifact.kind == "markdown": + sidecar = primary.with_suffix(".md") + if sidecar.exists(): + total += sidecar.stat().st_size + if artifact.size_bytes != total: + artifact.size_bytes = total + artifact.save(update_fields=["size_bytes"]) + + default_quota = 1024 * 1024 * 1024 # 1 GB + for user in User.objects.all(): + UserProfile.objects.get_or_create(user=user, defaults={"quota_bytes": default_quota}) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("artifacts", "0009_quota_and_keep"), + ] + + operations = [ + migrations.RunPython(backfill, noop_reverse), + ] diff --git a/artifacts/migrations/0011_add_asset.py b/artifacts/migrations/0011_add_asset.py new file mode 100644 index 0000000..f2bc2c0 --- /dev/null +++ b/artifacts/migrations/0011_add_asset.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.15 on 2026-07-24 10:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0010_backfill_sizes_and_profiles'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Asset', + fields=[ + ('id', models.CharField(max_length=12, primary_key=True, serialize=False)), + ('path', models.CharField(max_length=500)), + ('content_type', models.CharField(max_length=100)), + ('size_bytes', models.BigIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('artifact', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='artifacts.artifact')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/artifacts/migrations/__init__.py b/artifacts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/models.py b/artifacts/models.py new file mode 100644 index 0000000..3ce7209 --- /dev/null +++ b/artifacts/models.py @@ -0,0 +1,105 @@ +from django.conf import settings +from django.db import models + + +class Artifact(models.Model): + HTML_FRAGMENT = "html_fragment" + HTML_FULL = "html_full" + MARKDOWN = "markdown" + DECK = "deck" + IMAGE = "image" + PDF = "pdf" + BINARY = "binary" + + AGENT = "agent" + PERSON = "person" + SYSTEM = "system" + + KIND_CHOICES = [ + (HTML_FRAGMENT, "HTML fragment"), + (HTML_FULL, "Full HTML"), + (MARKDOWN, "Markdown"), + (DECK, "Deck"), + (IMAGE, "Image"), + (PDF, "PDF"), + (BINARY, "Binary"), + ] + + SOURCE_TYPE_CHOICES = [ + (AGENT, "Agent"), + (PERSON, "Person"), + (SYSTEM, "System"), + ] + + id = models.CharField(max_length=8, primary_key=True) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="artifacts") + source = models.CharField(max_length=80, db_index=True) + title = models.CharField(max_length=200) + kind = models.CharField(max_length=20, choices=KIND_CHOICES) + theme = models.CharField(max_length=40, blank=True) + day = models.DateField(db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + path = models.CharField(max_length=500) + original_kind = models.CharField(max_length=20) + tags = models.JSONField(default=list, blank=True) + source_prompt = models.TextField(blank=True) + source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES, default=AGENT) + source_name = models.CharField(max_length=120, blank=True) + source_id = models.CharField(max_length=120, blank=True) + metadata = models.JSONField(default=dict, blank=True) + read_at = models.DateTimeField(null=True, blank=True) + is_kept = models.BooleanField(default=False) + size_bytes = models.BigIntegerField(default=0) + + @property + def is_unread(self): + return self.read_at is None + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.title} ({self.source})" + + +class TagColor(models.Model): + name = models.CharField(max_length=40, primary_key=True) + color = models.CharField(max_length=20) + + def __str__(self): + return f"{self.name} ({self.color})" + + +class UserProfile(models.Model): + DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB + + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile") + quota_bytes = models.BigIntegerField(default=DEFAULT_QUOTA_BYTES) + + def __str__(self): + return f"{self.user} quota={self.quota_bytes}" + + +class Asset(models.Model): + """An image uploaded for embedding inside an artifact's content. + + Uploaded before the artifact exists, so `artifact` is null until the + artifact referencing its URL is created. Unattached assets are swept + after ASSET_ORPHAN_GRACE; attached ones die with their artifact. + """ + + id = models.CharField(max_length=12, primary_key=True) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="assets") + artifact = models.ForeignKey( + Artifact, on_delete=models.CASCADE, related_name="assets", null=True, blank=True + ) + path = models.CharField(max_length=500) + content_type = models.CharField(max_length=100) + size_bytes = models.BigIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.id} ({self.content_type})" diff --git a/artifacts/storage.py b/artifacts/storage.py new file mode 100644 index 0000000..36e7069 --- /dev/null +++ b/artifacts/storage.py @@ -0,0 +1,135 @@ +"""Disk-usage accounting, per-user quota, and automatic eviction. + +An artifact's on-disk footprint is the primary stored file plus, for markdown, +the preserved `.md` sidecar. We record it on the row (`size_bytes`) at ingest so +usage is a cheap SUM rather than a stat of every file. +""" + +import shutil +from datetime import timedelta + +from django.conf import settings +from django.db.models import Sum +from django.utils import timezone + +from .models import Artifact, Asset, UserProfile + + +class QuotaExceeded(Exception): + """Raised when a post cannot fit even after evicting every unkept artifact.""" + + +def artifact_disk_size(relative_path, kind): + """Bytes an artifact occupies on disk, including the markdown sidecar.""" + root = settings.ARTIFACTS_ROOT + total = 0 + primary = root / relative_path + if primary.exists(): + total += primary.stat().st_size + if kind == Artifact.MARKDOWN: + sidecar = primary.with_suffix(".md") + if sidecar.exists(): + total += sidecar.stat().st_size + return total + + +def delete_artifact_files(artifact): + """Remove an artifact's files from disk (primary, markdown sidecar, assets). + + Assets are cascade-deleted from the DB by the FK, but their files must be + removed here explicitly. + """ + root = settings.ARTIFACTS_ROOT + primary = root / artifact.path + primary.unlink(missing_ok=True) + if artifact.kind == Artifact.MARKDOWN: + primary.with_suffix(".md").unlink(missing_ok=True) + for asset in artifact.assets.all(): + delete_asset_file(asset) + + +def delete_asset_file(asset): + (settings.ARTIFACTS_ROOT / asset.path).unlink(missing_ok=True) + + +def artifact_total_bytes(artifact): + """An artifact's own bytes plus those of the assets embedded in it.""" + asset_bytes = artifact.assets.aggregate(total=Sum("size_bytes")).get("total") or 0 + return artifact.size_bytes + asset_bytes + + +def user_usage_bytes(user): + artifact_bytes = ( + Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0 + ) + asset_bytes = ( + Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0 + ) + return artifact_bytes + asset_bytes + + +def user_quota_bytes(user): + profile, _ = UserProfile.objects.get_or_create(user=user) + return profile.quota_bytes + + +def make_room_for(user, incoming_bytes): + """Evict oldest unkept artifacts until `incoming_bytes` fits under quota. + + Returns the list of evicted artifacts (already deleted). Raises QuotaExceeded + if even removing every unkept artifact leaves no room. + """ + quota = user_quota_bytes(user) + if incoming_bytes > quota: + raise QuotaExceeded("Artifact is larger than the entire quota.") + + used = user_usage_bytes(user) + if used + incoming_bytes <= quota: + return [] + + evicted = [] + unkept = ( + Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at") + ) + for artifact in unkept: + if used + incoming_bytes <= quota: + break + # Freeing an artifact also frees the assets embedded in it. + used -= artifact_total_bytes(artifact) + delete_artifact_files(artifact) + artifact.delete() + evicted.append(artifact) + + if used + incoming_bytes > quota: + raise QuotaExceeded("Quota is full of kept artifacts; free space to post.") + return evicted + + +ASSET_ORPHAN_GRACE = timedelta(hours=24) + + +def sweep_orphan_assets(user=None): + """Delete assets uploaded but never referenced by an artifact. + + Runs opportunistically on upload. The grace period gives an agent time to + upload images and then post the artifact that references them. + """ + cutoff = timezone.now() - ASSET_ORPHAN_GRACE + orphans = Asset.objects.filter(artifact__isnull=True, created_at__lt=cutoff) + if user is not None: + orphans = orphans.filter(owner=user) + count = 0 + for asset in orphans: + delete_asset_file(asset) + asset.delete() + count += 1 + return count + + +def disk_free_bytes(): + """Free space on the volume that holds the artifact store.""" + return shutil.disk_usage(settings.ARTIFACTS_ROOT).free + + +def total_allocated_bytes(): + return UserProfile.objects.aggregate(total=Sum("quota_bytes")).get("total") or 0 diff --git a/artifacts/urls.py b/artifacts/urls.py new file mode 100644 index 0000000..a3d1f65 --- /dev/null +++ b/artifacts/urls.py @@ -0,0 +1,21 @@ +from django.urls import path + +from . import views + + +urlpatterns = [ + path("", views.feed, name="feed"), + path("feed/older", views.feed_older, name="feed-older"), + path("a//read", views.mark_artifact_read, name="artifact-mark-read"), + path("a//keep", views.toggle_artifact_keep, name="artifact-toggle-keep"), + path("a/", views.artifact_view, name="artifact"), + path("assets/", views.asset_view, name="asset"), + path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"), + path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"), + path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), + path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), + path("settings/", views.settings_home, name="settings-home"), + path("settings/account/", views.settings_account, name="settings-account"), + path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"), + path("settings/storage/", views.settings_storage, name="settings-storage"), +] diff --git a/artifacts/views.py b/artifacts/views.py new file mode 100644 index 0000000..758b2c1 --- /dev/null +++ b/artifacts/views.py @@ -0,0 +1,903 @@ +import hashlib +import html +import json +import mimetypes +import re +import secrets +from datetime import timedelta +from pathlib import Path +from urllib.parse import urlencode + +import markdown +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import views as auth_views +from django.contrib.auth.decorators import login_required +from django.db.models import Count, Q, Sum +from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.template.defaultfilters import slugify +from django.urls import reverse +from django.utils.dateparse import parse_date +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.decorators.clickjacking import xframe_options_sameorigin +from django.views.decorators.http import require_POST +from rest_framework.authtoken.models import Token +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from .demo_data import create_demo_artifacts +from .forms import DaybookLoginForm +from .models import Artifact, Asset, TagColor +from .storage import ( + QuotaExceeded, + artifact_disk_size, + delete_artifact_files, + disk_free_bytes, + make_room_for, + sweep_orphan_assets, + total_allocated_bytes, + user_quota_bytes, + user_usage_bytes, +) + + +TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK} + +REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days + +MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image + +# Matches the asset id in a Daybook asset URL, e.g. /assets/ab12cd34ef56 +ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})") + + +def filter_by_label(queryset, label): + matching_ids = [artifact.id for artifact in queryset.only("id", "tags") if label in artifact.tags] + return queryset.filter(pk__in=matching_ids) + + +DEFAULT_THEMES = { + Artifact.HTML_FRAGMENT: "report", + Artifact.MARKDOWN: "report", + Artifact.DECK: "deck", +} + + +@method_decorator(login_required, name="get") +class ArtifactListCreateView(APIView): + parser_classes = [JSONParser, FormParser, MultiPartParser] + + def get(self, request): + artifacts = Artifact.objects.filter(owner=request.user) + if source := request.query_params.get("source"): + artifacts = artifacts.filter(source=source) + if label := request.query_params.get("label"): + artifacts = filter_by_label(artifacts, label) + if day := request.query_params.get("day"): + artifacts = artifacts.filter(day=day) + if kind := request.query_params.get("kind"): + artifacts = artifacts.filter(kind=kind) + if title_query := request.query_params.get("q"): + artifacts = artifacts.filter(title__icontains=title_query) + + return Response([serialize_artifact(artifact, request) for artifact in artifacts]) + + def post(self, request): + user = resolve_token_user(request) + if user is None: + return HttpResponseForbidden("Invalid or missing API token") + + if request.content_type and request.content_type.startswith("multipart/"): + artifact = create_binary_artifact(request.data, user) + if isinstance(artifact, Response): + return artifact + apply_tag_colors(request.data) + return Response(serialize_artifact(artifact, request), status=201) + + data = request.data + kind = data.get("kind") + if kind not in TEXT_KINDS: + return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400) + + required = ["source", "title", "content"] + missing = [field for field in required if not data.get(field)] + if missing: + return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400) + + source_error = validate_source_fields(data) + if source_error: + return source_error + + artifact = create_text_artifact(data, request, user) + if isinstance(artifact, Response): + return artifact + apply_tag_colors(data) + return Response(serialize_artifact(artifact, request), status=201) + + +class AssetUploadView(APIView): + """Upload an image for embedding inside artifact content. + + Returns a stable URL the agent puts in its markdown/fragment. The asset is + attached to whichever artifact later references that URL. + """ + + parser_classes = [MultiPartParser, FormParser] + + def post(self, request): + user = resolve_token_user(request) + if user is None: + return HttpResponseForbidden("Invalid or missing API token") + + uploaded = request.data.get("file") + if not uploaded: + return Response({"detail": "Missing required field: file"}, status=400) + + content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or "" + if not content_type.startswith("image/"): + return Response({"detail": "Only image uploads are supported."}, status=400) + if uploaded.size > MAX_ASSET_BYTES: + return Response( + {"detail": f"Image exceeds the {MAX_ASSET_BYTES // (1024 * 1024)} MB limit."}, + status=413, + ) + + sweep_orphan_assets(user) + + asset_id = generate_asset_id() + suffix = safe_suffix(uploaded.name, content_type) + directory = Path("_assets") / timezone.localdate().isoformat() + (settings.ARTIFACTS_ROOT / directory).mkdir(parents=True, exist_ok=True) + stored_path = directory / f"{asset_id}{suffix}" + + with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target: + for chunk in uploaded.chunks(): + target.write(chunk) + + size_bytes = (settings.ARTIFACTS_ROOT / stored_path).stat().st_size + try: + make_room_for(user, size_bytes) + except QuotaExceeded as exc: + (settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True) + return Response({"detail": str(exc)}, status=413) + + asset = Asset.objects.create( + id=asset_id, + owner=user, + path=stored_path.as_posix(), + content_type=content_type, + size_bytes=size_bytes, + ) + return Response( + { + "id": asset.id, + "url": request.build_absolute_uri(reverse("asset", args=[asset.id])), + "content_type": asset.content_type, + "size_bytes": asset.size_bytes, + }, + status=201, + ) + + +@login_required +@xframe_options_sameorigin +def asset_view(request, asset_id): + asset = get_object_or_404(Asset, pk=asset_id, owner=request.user) + path = safe_artifact_path(asset.path) + if not path.exists(): + raise Http404("Asset file not found") + return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream") + + +@method_decorator(login_required, name="get") +class ArtifactDetailView(APIView): + def get(self, request, artifact_id): + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) + return Response(serialize_artifact(artifact, request)) + + def patch(self, request, artifact_id): + user = resolve_token_user(request) + if user is None: + return HttpResponseForbidden("Invalid or missing API token") + + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) + data = request.data + if "title" in data: + if not data["title"]: + return Response({"detail": "title cannot be blank"}, status=400) + artifact.title = data["title"] + if "tags" in data: + artifact.tags = normalize_tags(data["tags"]) + if "source_prompt" in data: + artifact.source_prompt = data["source_prompt"] or "" + if "source_type" in data: + if data["source_type"] not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}: + return Response({"detail": "source_type must be agent, person, or system"}, status=400) + artifact.source_type = data["source_type"] + if "source_name" in data: + artifact.source_name = data["source_name"] or "" + if "source_id" in data: + artifact.source_id = data["source_id"] or "" + if "metadata" in data: + if not isinstance(data["metadata"], dict): + return Response({"detail": "metadata must be an object"}, status=400) + artifact.metadata = data["metadata"] + if "tag_colors" in data: + try: + apply_tag_colors(data) + except ValueError as exc: + return Response({"detail": str(exc)}, status=400) + artifact.save( + update_fields=[ + "title", + "tags", + "source_prompt", + "source_type", + "source_name", + "source_id", + "metadata", + ] + ) + return Response(serialize_artifact(artifact, request)) + + def delete(self, request, artifact_id): + user = resolve_token_user(request) + if user is None: + return HttpResponseForbidden("Invalid or missing API token") + + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user) + delete_artifact_files(artifact) + artifact.delete() + return Response(status=204) + + +class CapabilitiesView(APIView): + """Static capability info. Reachable by a logged-in session or a valid API token, + so agents can check connectivity before posting. Exposes no user data.""" + + def get(self, request): + if not request.user.is_authenticated and resolve_token_user(request) is None: + return HttpResponseForbidden("Login or a valid API token is required") + return Response( + { + "artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES], + "filters": ["source", "label", "day", "kind", "q"], + "views": ["rendered", "raw"], + "raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL], + "source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES], + "auth": "bearer_token", + "methods": { + "/api/artifacts": ["GET", "POST"], + "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], + "/api/assets": ["POST"], + }, + } + ) + + +FEED_WINDOW_DAYS = 7 + + +def apply_feed_filters(queryset, source="", label="", kind="", title_query=""): + if source: + queryset = queryset.filter(source=source) + if label: + queryset = filter_by_label(queryset, label) + if kind: + queryset = queryset.filter(kind=kind) + if title_query: + queryset = queryset.filter(title__icontains=title_query) + return queryset + + +def group_artifacts_by_day(artifacts, overrides): + """Turn a flat, reverse-chronological list into [{day, artifacts}] groups.""" + groups = [] + for artifact in artifacts: + # Source has its own column in the row, so chips are the free-form tags only. + artifact.label_chips = tags_with_colors(artifact.tags, overrides) + if not groups or groups[-1]["day"] != artifact.day: + groups.append({"day": artifact.day, "artifacts": []}) + groups[-1]["artifacts"].append(artifact) + return groups + + +@login_required +def feed(request): + selected_source = request.GET.get("source", "") + selected_label = request.GET.get("label", "") + selected_kind = request.GET.get("kind", "") + title_query = request.GET.get("q", "") + today = timezone.localdate() + selected_artifact_id = request.GET.get("artifact", "") + preview_none = request.GET.get("preview") == "none" + selected_view = "raw" if request.GET.get("view") == "raw" else "rendered" + + all_artifacts = Artifact.objects.filter(owner=request.user) + artifacts = apply_feed_filters( + all_artifacts, selected_source, selected_label, selected_kind, title_query + ) + + window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1) + if not artifacts.filter(day__gte=window_start).exists(): + # Nothing recent for these filters: fall back to the most recent days that + # do match, so selecting a source is never met with a blank list. + recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS]) + if recent_days: + window_start = recent_days[-1] + window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at")) + older_count = artifacts.filter(day__lt=window_start).count() + + overrides = tag_color_overrides() + day_groups = group_artifacts_by_day(window_artifacts, overrides) + + selected_artifact = None + if not preview_none: + selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None) + + toggle_query = {"view": "rendered"} + if selected_source: + toggle_query["source"] = selected_source + if selected_label: + toggle_query["label"] = selected_label + if selected_kind: + toggle_query["kind"] = selected_kind + if title_query: + toggle_query["q"] = title_query + if selected_artifact: + toggle_query["artifact"] = selected_artifact.id + rendered_url = f"?{urlencode(toggle_query)}" + toggle_query["view"] = "raw" + raw_url = f"?{urlencode(toggle_query)}" + + # Source counts come from the other filters only, so the sidebar still shows + # every source available under the current label/type/name filters. + source_scoped = apply_feed_filters( + all_artifacts, "", selected_label, selected_kind, title_query + ) + source_counts = ( + source_scoped.values("source") + .annotate(count=Count("id", filter=Q(read_at__isnull=True))) + .order_by("source") + ) + + all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags}) + return render( + request, + "artifacts/feed.html", + { + "artifacts": window_artifacts, + "day_groups": day_groups, + "older_count": older_count, + "window_start": window_start, + "source_counts": source_counts, + "total_count": source_scoped.filter(read_at__isnull=True).count(), + "all_labels": labels_with_colors_list(all_labels, overrides), + "raw_url": raw_url, + "rendered_url": rendered_url, + "selected_artifact": selected_artifact, + "selected_kind": selected_kind, + "selected_label": selected_label, + "selected_source": selected_source, + "selected_view": selected_view, + "preview_none": preview_none, + "today": today, + "title_query": title_query, + "kind_choices": Artifact.KIND_CHOICES, + }, + ) + + +@login_required +@require_POST +def mark_artifact_read(request, artifact_id): + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) + if artifact.read_at is None: + artifact.read_at = timezone.now() + artifact.save(update_fields=["read_at"]) + return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()}) + + +@login_required +@require_POST +def toggle_artifact_keep(request, artifact_id): + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) + artifact.is_kept = not artifact.is_kept + artifact.save(update_fields=["is_kept"]) + return JsonResponse({"id": artifact.id, "is_kept": artifact.is_kept}) + + +@login_required +def feed_older(request): + """Return the next page of older day-groups as an HTML fragment for infinite scroll.""" + before = parse_date(request.GET.get("before", "")) or timezone.localdate() + artifacts = apply_feed_filters( + Artifact.objects.filter(owner=request.user), + request.GET.get("source", ""), + request.GET.get("label", ""), + request.GET.get("kind", ""), + request.GET.get("q", ""), + ).filter(day__lt=before).order_by("-created_at") + + # Take a whole page of days at a time so a day is never split across pages. + page_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS]) + if not page_days: + return HttpResponse("") + + page_artifacts = list(artifacts.filter(day__gte=page_days[-1])) + day_groups = group_artifacts_by_day(page_artifacts, tag_color_overrides()) + remaining = artifacts.filter(day__lt=page_days[-1]).count() + + return render( + request, + "artifacts/_day_groups.html", + { + "day_groups": day_groups, + "next_before": page_days[-1].isoformat(), + "has_more": remaining > 0, + "selected_artifact": None, + "selected_source": request.GET.get("source", ""), + "selected_label": request.GET.get("label", ""), + "selected_kind": request.GET.get("kind", ""), + "title_query": request.GET.get("q", ""), + "selected_view": "raw" if request.GET.get("view") == "raw" else "rendered", + "today": timezone.localdate(), + }, + ) + + +@login_required +@xframe_options_sameorigin +def artifact_view(request, artifact_id): + artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user) + if request.GET.get("raw") == "1": + raw_path = raw_artifact_path(artifact) + if raw_path and raw_path.exists(): + return FileResponse(raw_path.open("rb"), content_type="text/plain; charset=utf-8") + return HttpResponse(raw_unavailable_html(artifact), content_type="text/html") + + path = safe_artifact_path(artifact.path) + if not path.exists(): + raise Http404("Artifact file not found") + + content_type, _ = mimetypes.guess_type(path.name) + return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream") + + +def create_text_artifact(data, request, user): + now = timezone.localtime() + artifact_id = generate_id() + source = slugify(data["source"]) or "unknown" + kind = data["kind"] + theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base") + title = data["title"] + content = data["content"] + + directory = Path(source) / now.date().isoformat() + target_dir = settings.ARTIFACTS_ROOT / directory + target_dir.mkdir(parents=True, exist_ok=True) + + if kind == Artifact.HTML_FULL: + rendered = content + elif kind == Artifact.MARKDOWN: + rendered = wrap_html(markdown.markdown(content, extensions=["extra", "sane_lists"]), title, theme, request) + (target_dir / f"{artifact_id}.md").write_text(content, encoding="utf-8") + elif kind == Artifact.DECK: + rendered = wrap_deck(content, title, request) + else: + rendered = wrap_html(content, title, theme, request) + + stored_path = directory / f"{artifact_id}.html" + (settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8") + + size_bytes = artifact_disk_size(stored_path.as_posix(), kind) + try: + make_room_for(user, size_bytes) + except QuotaExceeded as exc: + (settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True) + if kind == Artifact.MARKDOWN: + (target_dir / f"{artifact_id}.md").unlink(missing_ok=True) + return Response({"detail": str(exc)}, status=413) + + artifact = Artifact.objects.create( + id=artifact_id, + owner=user, + source=source, + title=title, + kind=kind, + theme=theme, + day=now.date(), + path=stored_path.as_posix(), + original_kind=kind, + tags=normalize_tags(data.get("tags", [])), + source_prompt=data.get("source_prompt", ""), + size_bytes=size_bytes, + **source_fields(data, source), + ) + attach_referenced_assets(artifact, content) + return artifact + + +def create_binary_artifact(data, user): + uploaded = data.get("file") + required = ["source", "title"] + missing = [field for field in required if not data.get(field)] + if not uploaded: + missing.append("file") + if missing: + return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400) + source_error = validate_source_fields(data) + if source_error: + return source_error + + now = timezone.localtime() + artifact_id = generate_id() + source = slugify(data["source"]) or "unknown" + title = data["title"] + content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or "" + kind = binary_kind(content_type) + + directory = Path(source) / now.date().isoformat() + target_dir = settings.ARTIFACTS_ROOT / directory + target_dir.mkdir(parents=True, exist_ok=True) + + suffix = safe_suffix(uploaded.name, content_type) + stored_path = directory / f"{artifact_id}{suffix}" + with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target: + for chunk in uploaded.chunks(): + target.write(chunk) + + size_bytes = artifact_disk_size(stored_path.as_posix(), kind) + try: + make_room_for(user, size_bytes) + except QuotaExceeded as exc: + (settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True) + return Response({"detail": str(exc)}, status=413) + + return Artifact.objects.create( + id=artifact_id, + owner=user, + source=source, + title=title, + kind=kind, + theme="", + day=now.date(), + path=stored_path.as_posix(), + original_kind=kind, + tags=normalize_tags(data.get("tags", [])), + source_prompt=data.get("source_prompt", ""), + size_bytes=size_bytes, + **source_fields(data, source), + ) + + +def wrap_html(body, title, theme, request): + stylesheet = request.build_absolute_uri(f"/static/themes/{theme}.css") + return f""" + + + + + {html.escape(title)} + + + +{body} + + +""" + + +def wrap_deck(body, title, request): + stylesheet = request.build_absolute_uri("/static/themes/deck.css") + return f""" + + + + + {html.escape(title)} + + + +
{body}
+ + +""" + + +def generate_id(): + while True: + artifact_id = secrets.token_hex(4) + if not Artifact.objects.filter(pk=artifact_id).exists(): + return artifact_id + + +def generate_asset_id(): + while True: + asset_id = secrets.token_hex(6) + if not Asset.objects.filter(pk=asset_id).exists(): + return asset_id + + +def attach_referenced_assets(artifact, content): + """Link assets whose URLs appear in the artifact's content. + + Assets are uploaded before the artifact exists, so this runs at creation + time and ties each referenced asset to the artifact that embeds it. + """ + referenced = set(ASSET_URL_RE.findall(content or "")) + if not referenced: + return 0 + return Asset.objects.filter( + owner=artifact.owner, id__in=referenced, artifact__isnull=True + ).update(artifact=artifact) + + +def resolve_token_user(request): + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + return None + key = auth[len("Bearer "):].strip() + if not key: + return None + try: + return Token.objects.select_related("user").get(key=key).user + except Token.DoesNotExist: + return None + + +def normalize_tags(tags): + if isinstance(tags, str): + return [tag.strip() for tag in tags.split(",") if tag.strip()] + if isinstance(tags, list): + return [str(tag).strip() for tag in tags if str(tag).strip()] + return [] + + +HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") + +TAG_PALETTE = [ + "#4f6df5", "#e0578c", "#2fa88f", "#d98b2b", + "#8c5fd6", "#3fa1d8", "#c14f4f", "#5aa552", +] + + +def tag_color(name, overrides): + if override := overrides.get(name): + return override + index = int(hashlib.sha256(name.encode("utf-8")).hexdigest(), 16) % len(TAG_PALETTE) + return TAG_PALETTE[index] + + +def tag_color_overrides(): + return dict(TagColor.objects.values_list("name", "color")) + + +def tags_with_colors(tags, overrides=None): + overrides = overrides if overrides is not None else tag_color_overrides() + return [{"name": tag, "color": tag_color(tag, overrides)} for tag in tags] + + +def labels_with_colors_list(labels, overrides=None): + overrides = overrides if overrides is not None else tag_color_overrides() + return [{"name": label, "color": tag_color(label, overrides)} for label in labels] + + +def labels_with_colors(artifact, overrides=None): + return labels_with_colors_list([artifact.source, *artifact.tags], overrides) + + +def validate_source_fields(data): + if source_type := data.get("source_type"): + if source_type not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}: + return Response({"detail": "source_type must be agent, person, or system"}, status=400) + try: + parse_metadata_value(data.get("metadata", {})) + except ValueError as exc: + return Response({"detail": str(exc)}, status=400) + try: + parse_tag_colors_value(data.get("tag_colors", {})) + except ValueError as exc: + return Response({"detail": str(exc)}, status=400) + return None + + +def parse_tag_colors_value(value): + if value in (None, ""): + return {} + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError("tag_colors must be an object") from exc + if not isinstance(value, dict): + raise ValueError("tag_colors must be an object") + for tag, color in value.items(): + if not HEX_COLOR_RE.match(str(color)): + raise ValueError(f"tag_colors[{tag}] must be a hex color like #RRGGBB") + return value + + +def apply_tag_colors(data): + tag_colors = parse_tag_colors_value(data.get("tag_colors", {})) + for tag, color in tag_colors.items(): + TagColor.objects.update_or_create(name=tag, defaults={"color": color}) + + +def source_fields(data, source): + return { + "source_type": data.get("source_type") or Artifact.AGENT, + "source_name": data.get("source_name") or source, + "source_id": data.get("source_id") or "", + "metadata": parse_metadata_value(data.get("metadata", {})), + } + + +def parse_metadata_value(value): + if value in (None, ""): + return {} + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError("metadata must be an object") from exc + if isinstance(parsed, dict): + return parsed + raise ValueError("metadata must be an object") + + +def binary_kind(content_type): + if content_type.startswith("image/"): + return Artifact.IMAGE + if content_type == "application/pdf": + return Artifact.PDF + return Artifact.BINARY + + +def safe_suffix(filename, content_type): + suffix = Path(filename).suffix.lower() + if suffix and suffix[1:].isalnum() and len(suffix) <= 10: + return suffix + return mimetypes.guess_extension(content_type) or ".bin" + + +def serialize_artifact(artifact, request): + return { + "id": artifact.id, + "url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])), + "api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])), + "raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"), + "raw_available": raw_artifact_path(artifact) is not None, + "source": artifact.source, + "title": artifact.title, + "kind": artifact.kind, + "theme": artifact.theme, + "day": artifact.day.isoformat(), + "created_at": artifact.created_at.isoformat(), + "tags": tags_with_colors(artifact.tags), + "labels": labels_with_colors(artifact), + "source_prompt": artifact.source_prompt, + "source_type": artifact.source_type, + "source_name": artifact.source_name, + "source_id": artifact.source_id, + "metadata": artifact.metadata, + "read_at": artifact.read_at.isoformat() if artifact.read_at else None, + "is_kept": artifact.is_kept, + "size_bytes": artifact.size_bytes, + } + + +def safe_artifact_path(relative_path): + root = settings.ARTIFACTS_ROOT.resolve() + path = (root / relative_path).resolve() + if root not in path.parents and path != root: + raise Http404("Invalid artifact path") + return path + + +def raw_artifact_path(artifact): + path = safe_artifact_path(artifact.path) + if artifact.kind == Artifact.MARKDOWN: + return path.with_suffix(".md") + if artifact.kind == Artifact.HTML_FULL: + return path + return None + + +class DaybookLoginView(auth_views.LoginView): + template_name = "artifacts/login.html" + authentication_form = DaybookLoginForm + + def form_valid(self, form): + response = super().form_valid(form) + if form.cleaned_data.get("remember_me"): + self.request.session.set_expiry(REMEMBER_ME_SESSION_AGE) + else: + self.request.session.set_expiry(0) + return response + + +@login_required +def settings_home(request): + return redirect("settings-account") + + +@login_required +def settings_account(request): + token, _ = Token.objects.get_or_create(user=request.user) + if request.method == "POST" and request.POST.get("action") == "regenerate": + token.delete() + token = Token.objects.create(user=request.user) + messages.success(request, "API token regenerated.") + return redirect("settings-account") + return render( + request, + "artifacts/settings_account.html", + {"token": token, "active_section": "account"}, + ) + + +@login_required +def settings_demo_data(request): + if request.method == "POST": + count = create_demo_artifacts(request.user, request) + messages.success(request, f"Created {count} demo artifacts.") + return redirect("settings-demo-data") + return render( + request, + "artifacts/settings_demo_data.html", + {"active_section": "demo-data"}, + ) + + +@login_required +def settings_storage(request): + used = user_usage_bytes(request.user) + quota = user_quota_bytes(request.user) + kept_bytes = ( + Artifact.objects.filter(owner=request.user, is_kept=True) + .aggregate(total=Sum("size_bytes")) + .get("total") + or 0 + ) + context = { + "active_section": "storage", + "used_bytes": used, + "quota_bytes": quota, + "kept_bytes": kept_bytes, + "percent_used": min(100, round(used / quota * 100)) if quota else 0, + "artifact_count": Artifact.objects.filter(owner=request.user).count(), + "kept_count": Artifact.objects.filter(owner=request.user, is_kept=True).count(), + } + if request.user.is_superuser: + context["is_superuser"] = True + context["total_allocated"] = total_allocated_bytes() + context["disk_free"] = disk_free_bytes() + return render(request, "artifacts/settings_storage.html", context) + + +def raw_unavailable_html(artifact): + return f""" + + + + + Raw source unavailable + + + +
+

Raw source unavailable

+

Daybook does not have preserved raw source for {html.escape(artifact.title)}.

+
+ + +""" diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/daybook_project/asgi.py b/daybook_project/asgi.py new file mode 100644 index 0000000..ff1dd2e --- /dev/null +++ b/daybook_project/asgi.py @@ -0,0 +1,8 @@ +import os + +from django.core.asgi import get_asgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings") + +application = get_asgi_application() diff --git a/daybook_project/settings.py b/daybook_project/settings.py new file mode 100644 index 0000000..3b396a6 --- /dev/null +++ b/daybook_project/settings.py @@ -0,0 +1,82 @@ +import os +from pathlib import Path + + +BASE_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve() + +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me") +DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"} +ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework.authtoken", + "artifacts", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "daybook_project.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "daybook_project.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.environ.get("DATABASE_PATH", DATA_DIR / "db.sqlite3"), + } +} + +AUTH_PASSWORD_VALIDATORS = [] + +LOGIN_URL = "login" +LOGIN_REDIRECT_URL = "feed" + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATICFILES_DIRS = [BASE_DIR / "static"] +STATIC_ROOT = BASE_DIR / "staticfiles" +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +ARTIFACTS_ROOT = Path(os.environ.get("ARTIFACTS_ROOT", DATA_DIR / "artifacts_store")) diff --git a/daybook_project/urls.py b/daybook_project/urls.py new file mode 100644 index 0000000..4204d51 --- /dev/null +++ b/daybook_project/urls.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from django.contrib.auth import views as auth_views +from django.urls import include, path + +from artifacts.views import DaybookLoginView + + +urlpatterns = [ + path("admin/", admin.site.urls), + path("accounts/login/", DaybookLoginView.as_view(), name="login"), + path("accounts/logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"), + path("", include("artifacts.urls")), +] diff --git a/daybook_project/wsgi.py b/daybook_project/wsgi.py new file mode 100644 index 0000000..4c9ad58 --- /dev/null +++ b/daybook_project/wsgi.py @@ -0,0 +1,8 @@ +import os + +from django.core.wsgi import get_wsgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "daybook_project.settings") + +application = get_wsgi_application() diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..38e2602 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/requirements.txt +RUN pip install --upgrade pip \ + && pip install -r /app/requirements.txt + +COPY . /app +RUN sed -i 's/\r$//' /app/deploy/entrypoint.sh \ + && chmod +x /app/deploy/entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/app/deploy/entrypoint.sh"] +CMD ["gunicorn", "daybook_project.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..ee1dd7a --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,19 @@ +services: + daybook: + build: + context: .. + dockerfile: deploy/Dockerfile + container_name: daybook + restart: unless-stopped + env_file: + - .env + ports: + - "8000:8000" + volumes: + - ./data:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/api/capabilities"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100644 index 0000000..cdd620d --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -eu + +mkdir -p "${DAYBOOK_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}" + +python manage.py migrate --noinput +python manage.py collectstatic --noinput + +exec "$@" diff --git a/deploy/env.example b/deploy/env.example new file mode 100644 index 0000000..e83e248 --- /dev/null +++ b/deploy/env.example @@ -0,0 +1,7 @@ +DJANGO_DEBUG=0 +DJANGO_SECRET_KEY=replace-with-a-long-random-secret +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-proxmox-container-ip,your-domain.example +DAYBOOK_API_TOKEN=replace-with-a-long-random-token +DAYBOOK_DATA_DIR=/data +DATABASE_PATH=/data/db.sqlite3 +ARTIFACTS_ROOT=/data/artifacts_store diff --git a/deploy/proxmox_llm_deploy.md b/deploy/proxmox_llm_deploy.md new file mode 100644 index 0000000..2d01b1d --- /dev/null +++ b/deploy/proxmox_llm_deploy.md @@ -0,0 +1,348 @@ +# Proxmox Container Deployment Runbook For LLM Operators + +This document is written for an LLM or automation agent that is deploying Daybook into a Linux container on a Proxmox host. + +The recommended deployment target is a Proxmox LXC or VM running Debian/Ubuntu with Docker and Docker Compose. Daybook itself runs as a Docker container inside that guest. + +## Deployment Model + +```text +Proxmox host + Debian/Ubuntu LXC or VM + Docker Engine + daybook container + /app application code baked into image + /data persistent mounted volume +``` + +Persistent runtime data lives outside the image in `deploy/data` on the guest: + +```text +deploy/data/ + db.sqlite3 + artifacts_store/ +``` + +Do not store runtime data inside the image. + +## Files In This Directory + +| File | Purpose | +|---|---| +| `Dockerfile` | Builds the Daybook image. | +| `docker-compose.yml` | Runs Daybook with a persistent `./data:/data` bind mount. | +| `entrypoint.sh` | Runs migrations, collects static files, then starts Gunicorn. | +| `env.example` | Template for deployment environment variables. | +| `proxmox_llm_deploy.md` | This runbook. | + +## Prerequisites On The Proxmox Guest + +The guest OS should have: + +- Docker Engine +- Docker Compose plugin +- Git +- Network access to pull Python base images and Python packages + +For Debian/Ubuntu guests, install basics: + +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates curl git +``` + +Install Docker using Docker's current official instructions for the guest OS. Then verify: + +```bash +docker --version +docker compose version +``` + +## Clone Or Copy The Project + +Choose a stable application path on the guest: + +```bash +sudo mkdir -p /opt/daybook +sudo chown "$USER:$USER" /opt/daybook +``` + +If using Git: + +```bash +git clone /opt/daybook +cd /opt/daybook +``` + +If files were copied by another mechanism, ensure the project root contains: + +```text +manage.py +requirements.txt +deploy/docker-compose.yml +deploy/Dockerfile +``` + +## Create The Environment File + +From the project root: + +```bash +cd /opt/daybook +cp deploy/env.example deploy/.env +``` + +Generate secrets: + +```bash +python3 - <<'PY' +import secrets +print('DJANGO_SECRET_KEY=' + secrets.token_urlsafe(50)) +print('DAYBOOK_API_TOKEN=' + secrets.token_urlsafe(32)) +PY +``` + +Edit `deploy/.env`: + +```bash +nano deploy/.env +``` + +Set at minimum: + +```text +DJANGO_DEBUG=0 +DJANGO_SECRET_KEY= +DJANGO_ALLOWED_HOSTS=,,localhost,127.0.0.1 +DAYBOOK_API_TOKEN= +DAYBOOK_DATA_DIR=/data +DATABASE_PATH=/data/db.sqlite3 +ARTIFACTS_ROOT=/data/artifacts_store +``` + +Important: + +- Do not commit `deploy/.env`. +- Do not use the development token `dev-token` for a real deployment. +- Include the actual IP or DNS name users will use in `DJANGO_ALLOWED_HOSTS`. + +## Create Persistent Data Directory + +From the project root: + +```bash +mkdir -p deploy/data +``` + +The compose file mounts this path into the container as `/data`. + +## Build And Start + +From the project root: + +```bash +docker compose -f deploy/docker-compose.yml up -d --build +``` + +Watch logs: + +```bash +docker compose -f deploy/docker-compose.yml logs -f daybook +``` + +Expected startup behavior: + +- Django migrations run. +- Static files are collected. +- Gunicorn starts on port `8000` inside the container. +- Host port `8000` maps to container port `8000`. + +## Verify Deployment + +Check container state: + +```bash +docker compose -f deploy/docker-compose.yml ps +``` + +Check capabilities endpoint from the guest: + +```bash +curl -fsS http://127.0.0.1:8000/api/capabilities +``` + +Expected JSON includes: + +```json +{ + "artifact_kinds": ["html_fragment", "html_full", "markdown", "deck", "image", "pdf", "binary"], + "auth": "bearer_token" +} +``` + +Open the app from another machine: + +```text +http://:8000/ +``` + +If this fails: + +- Confirm the Proxmox guest firewall allows TCP `8000`. +- Confirm any Proxmox firewall rules allow TCP `8000`. +- Confirm `DJANGO_ALLOWED_HOSTS` includes the hostname/IP being used. + +## Publish A Smoke-Test Artifact + +Replace `` with `DAYBOOK_API_TOKEN` from `deploy/.env`. + +```bash +curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "persona": "deploy-smoke", + "source_type": "agent", + "source_name": "Deployment Smoke Agent", + "kind": "markdown", + "title": "Deployment Smoke Test", + "content": "# Deployment Smoke Test\n\nDaybook accepted an artifact.", + "tags": ["deploy", "smoke"], + "metadata": {"run_id": "deploy-smoke-001"} + }' +``` + +Then refresh: + +```text +http://:8000/ +``` + +## Operational Commands + +Stop: + +```bash +docker compose -f deploy/docker-compose.yml down +``` + +Start: + +```bash +docker compose -f deploy/docker-compose.yml up -d +``` + +Rebuild after code changes: + +```bash +docker compose -f deploy/docker-compose.yml up -d --build +``` + +View logs: + +```bash +docker compose -f deploy/docker-compose.yml logs -f daybook +``` + +Run Django management command: + +```bash +docker compose -f deploy/docker-compose.yml exec daybook python manage.py check +``` + +Open shell: + +```bash +docker compose -f deploy/docker-compose.yml exec daybook sh +``` + +## Backup + +Back up the guest directory: + +```text +/opt/daybook/deploy/data +``` + +It contains: + +- SQLite database +- Artifact files + +Simple backup command: + +```bash +tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data +``` + +For consistent backups during heavy writes, stop the container first: + +```bash +docker compose -f /opt/daybook/deploy/docker-compose.yml down +tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data +docker compose -f /opt/daybook/deploy/docker-compose.yml up -d +``` + +## Restore + +Stop Daybook: + +```bash +docker compose -f /opt/daybook/deploy/docker-compose.yml down +``` + +Replace `/opt/daybook/deploy/data` with the restored data directory, then start: + +```bash +docker compose -f /opt/daybook/deploy/docker-compose.yml up -d +``` + +## Updating The App + +If deployed from Git: + +```bash +cd /opt/daybook +git pull +docker compose -f deploy/docker-compose.yml up -d --build +``` + +The container entrypoint runs migrations on startup. + +## Reverse Proxy Notes + +If putting Daybook behind Nginx, Caddy, Traefik, or a Proxmox-level reverse proxy: + +- Proxy to `http://:8000`. +- Add the public hostname to `DJANGO_ALLOWED_HOSTS`. +- Terminate TLS at the proxy. +- Keep `DJANGO_DEBUG=0`. + +Example `DJANGO_ALLOWED_HOSTS`: + +```text +DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1 +``` + +## LLM Deployment Checklist + +Before declaring deployment complete, verify all items: + +- `deploy/.env` exists and does not use placeholder secrets. +- `DJANGO_DEBUG=0`. +- `DJANGO_ALLOWED_HOSTS` includes the access hostname/IP. +- `deploy/data/` exists. +- `docker compose -f deploy/docker-compose.yml ps` shows `daybook` running or healthy. +- `curl -fsS http://127.0.0.1:8000/api/capabilities` succeeds on the guest. +- Browser opens `http://:8000/`. +- Smoke-test artifact POST succeeds with the configured bearer token. +- `deploy/data/db.sqlite3` exists after startup. +- `deploy/data/artifacts_store/` exists after posting an artifact. + +## Known MVP Constraints + +- SQLite is used by default. This is acceptable for local/self-hosted MVP usage. +- The API uses a single shared bearer token. +- No reader authentication is implemented yet. +- Manual drag/drop upload is deferred. +- Content revisions are deferred. diff --git a/docs/agent_quickstart.md b/docs/agent_quickstart.md new file mode 100644 index 0000000..28d4fe6 --- /dev/null +++ b/docs/agent_quickstart.md @@ -0,0 +1,140 @@ +# Agent Quickstart + +This is the shortest path for an agent to publish and retrieve artifacts from Daybook. + +## 0. Get Your API Token + +Log in to the app in a browser and copy your personal token from **Settings → Account** +(`http://127.0.0.1:8001/settings/account/`). Every artifact you post with it lands in your +feed, and only yours. + +## 1. Check Capabilities + +Also confirms your token is valid: + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" ` + -Headers @{ Authorization = "Bearer " } +``` + +## 2. Publish Markdown + +```powershell +$body = @{ + source = "hermes-research" + source_type = "agent" + source_name = "Hermes Agent" + source_id = "hermes-local" + kind = "markdown" + title = "Agent Status Update" + content = "# Status Update`n`nThe agent completed its run successfully." + tags = @("status", "agent") + metadata = @{ + run_id = "run-001" + model = "gpt-5.5" + } +} | ConvertTo-Json -Depth 5 + +$artifact = Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts" ` + -Method Post ` + -Headers @{ Authorization = "Bearer " } ` + -ContentType "application/json" ` + -Body $body + +$artifact +``` + +Open the artifact in the browser: + +```powershell +$artifact.url +``` + +## 3. Publish HTML Fragment + +```powershell +$body = @{ + source = "athena" + source_type = "agent" + source_name = "Athena Agent" + kind = "html_fragment" + theme = "tasks" + title = "Task List" + content = "

Tasks

  • Review API docs
  • Publish next artifact
" + tags = @("tasks") +} | ConvertTo-Json -Depth 5 + +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts" ` + -Method Post ` + -Headers @{ Authorization = "Bearer " } ` + -ContentType "application/json" ` + -Body $body +``` + +## 4. Publish A File + +```powershell +curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` + -H "Authorization: Bearer " ` + -F "source=orion" ` + -F "source_type=agent" ` + -F "source_name=Orion Agent" ` + -F "title=Generated PDF" ` + -F "tags=pdf,report" ` + -F "file=@C:\path\to\report.pdf;type=application/pdf" +``` + +## 5. Find Recent Artifacts + +> **Note:** artifact read endpoints (steps 5 and 6) require a logged-in **browser session**; +> a bearer token is not accepted. These are for browsing from the app, not for agents. +> Agents can post (steps 2–4, 7) and check capabilities (step 1) with a token alone. + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?source=hermes-research" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?label=status" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?kind=markdown&q=status" +``` + +## 6. Read One Artifact Metadata + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" +``` + +## 7. Update Metadata + +```powershell +$body = @{ + tags = @("status", "agent", "reviewed") + metadata = @{ + run_id = "run-001" + model = "gpt-5.5" + reviewed = $true + } +} | ConvertTo-Json -Depth 5 + +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" ` + -Method Patch ` + -Headers @{ Authorization = "Bearer " } ` + -ContentType "application/json" ` + -Body $body +``` + +## Agent Payload Checklist + +Recommended fields for every agent post: + +- `source`: stable label identifying what posted this (agent-profile, script, or person name). +- `source_type`: usually `agent`. +- `source_name`: readable agent name. +- `source_id`: stable ID if the agent has one. +- `title`: useful human title. +- `kind`: artifact type. +- `content` or `file`: artifact content. +- `tags`: small set of useful filters. +- `metadata.run_id`: lets later artifacts be correlated to the same agent run. +- `metadata.model`: model or system that generated the artifact. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..4cfce20 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,425 @@ +# Daybook API + +Daybook is built around agents publishing artifacts. Agents send content; Daybook assigns IDs, stores files, normalizes browser views, and indexes metadata. + +Base URL while running locally: + +```text +http://127.0.0.1:8001 +``` + +Authentication for write endpoints (POST/PATCH) — a personal per-user API token, from Settings → Account in the app: + +```http +Authorization: Bearer +``` + +Every artifact belongs to the user whose token authenticated the POST — feeds are private per user. + +Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a +logged-in browser session, not a bearer token — visit the app in a browser and log in first. + +`GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check +connectivity before posting. + +## Artifact Kinds + +Currently supported kinds: + +| Kind | Upload type | Rendered behavior | Raw support | +|---|---|---|---| +| `html_fragment` | JSON text | Wrapped with a Daybook theme | No | +| `html_full` | JSON text | Stored as complete HTML | Yes | +| `markdown` | JSON text | Rendered to HTML and original `.md` is preserved | Yes | +| `deck` | JSON text | Wrapped as a simple HTML slide/deck page | No | +| `image` | Multipart file | Browser image preview | No | +| `pdf` | Multipart file | Browser PDF preview | No | +| `binary` | Multipart file | Stored and downloadable | No | + +## Source Fields + +Every artifact can identify who or what produced it. `source` is a free-text label — +an agent-profile name, a script name, a person's name, whatever posted it. + +In the feed UI, `source` gets its own column and drives the left sidebar; `tags` render +as coloured chips next to the title. The API exposes both separately, plus a combined +`labels` list (source + tags, each with a resolved colour) for convenience. + +| Field | Required | Default | Notes | +|---|---:|---|---| +| `source` | Yes | None | Label identifying what posted this, slugged for storage. | +| `source_type` | No | `agent` | One of `agent`, `person`, `system`. | +| `source_name` | No | `source` | Human-readable source name. | +| `source_id` | No | Empty string | Stable external agent/person/system ID. | +| `metadata` | No | `{}` | Free-form object for model/run/tool metadata. | + +Example source metadata: + +```json +{ + "source": "hermes-research", + "source_type": "agent", + "source_name": "Hermes Agent", + "source_id": "agent-hermes-prod", + "metadata": { + "model": "gpt-5.5", + "run_id": "run-2026-06-10-001", + "tool": "nightly-brief" + } +} +``` + +## Create Text Artifact + +Endpoint: + +```http +POST /api/artifacts +Content-Type: application/json +Authorization: Bearer +``` + +Required fields: + +| Field | Notes | +|---|---| +| `source` | Required. | +| `kind` | One of `html_fragment`, `html_full`, `markdown`, `deck`. | +| `title` | Required. | +| `content` | Required text content. | + +Optional fields: + +| Field | Notes | +|---|---| +| `theme` | Defaults by kind. Common values: `report`, `tasks`, `deck`, `base`. | +| `tags` | Array or comma-separated string. | +| `tag_colors` | Object mapping tag name → `#RRGGBB`. Sets a colour override for those tags. | +| `source_prompt` | Prompt or instruction that led to the artifact. | +| `source_type` | Defaults to `agent`. | +| `source_name` | Defaults to `source`. | +| `source_id` | Optional stable source ID. | +| `metadata` | Optional object. | + +### Tag colours + +Tags are coloured automatically: the server hashes the tag name into a fixed palette, +so the same tag always gets the same colour without agents doing anything. + +To force a specific colour, pass `tag_colors`: + +```json +{"tags": ["urgent", "billing"], "tag_colors": {"urgent": "#d1453b"}} +``` + +Colour overrides are **global and shared across all users** — setting a colour for +`urgent` changes it everywhere, for everyone. Values must be `#RRGGBB`; anything else +returns `400`. Overrides can also be set via `PATCH` on an existing artifact. + +PowerShell example: + +```powershell +$body = @{ + source = "hermes-research" + source_type = "agent" + source_name = "Hermes Agent" + source_id = "agent-hermes-local" + kind = "markdown" + theme = "report" + title = "Morning Brief" + content = "# Morning Brief`n`n- Review overnight output`n- Plan next actions" + tags = @("brief", "daily") + source_prompt = "Prepare a concise morning brief." + metadata = @{ + model = "gpt-5.5" + run_id = "brief-001" + } +} | ConvertTo-Json -Depth 5 + +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts" ` + -Method Post ` + -Headers @{ Authorization = "Bearer " } ` + -ContentType "application/json" ` + -Body $body +``` + +Response shape: + +```json +{ + "id": "a1b2c3d4", + "url": "http://127.0.0.1:8001/a/a1b2c3d4", + "api_url": "http://127.0.0.1:8001/api/artifacts/a1b2c3d4", + "raw_url": "http://127.0.0.1:8001/a/a1b2c3d4?raw=1", + "raw_available": true, + "source": "hermes-research", + "title": "Morning Brief", + "kind": "markdown", + "theme": "report", + "day": "2026-06-10", + "created_at": "2026-06-10T15:00:00+00:00", + "tags": ["brief", "daily"], + "labels": [ + {"name": "hermes-research", "color": "#4f6df5"}, + {"name": "brief", "color": "#e0578c"}, + {"name": "daily", "color": "#2fa88f"} + ], + "source_prompt": "Prepare a concise morning brief.", + "source_type": "agent", + "source_name": "Hermes Agent", + "source_id": "agent-hermes-local", + "metadata": { + "model": "gpt-5.5", + "run_id": "brief-001" + }, + "read_at": null +} +``` + +`read_at` is `null` until the artifact is opened in the feed viewer, then holds the +timestamp it was first read. Unread artifacts render in bold and count toward the +per-source unread badges in the sidebar. + +## Create File Artifact + +Endpoint: + +```http +POST /api/artifacts +Content-Type: multipart/form-data +Authorization: Bearer +``` + +Required fields: + +| Field | Notes | +|---|---| +| `source` | Required. | +| `title` | Required. | +| `file` | Required file part. | + +File kind is inferred from content type: + +| Content type | Artifact kind | +|---|---| +| `image/*` | `image` | +| `application/pdf` | `pdf` | +| Anything else | `binary` | + +PowerShell/curl example: + +```powershell +curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` + -H "Authorization: Bearer " ` + -F "source=orion" ` + -F "source_type=agent" ` + -F "source_name=Orion Agent" ` + -F "title=Architecture Diagram" ` + -F "tags=diagram,architecture" ` + -F "file=@C:\path\to\diagram.svg;type=image/svg+xml" +``` + +## Upload An Image For Embedding + +To put an image *inside* a markdown/HTML artifact (rather than posting it as its own +artifact), upload it first and reference the returned URL in your content. + +```http +POST /api/assets +Content-Type: multipart/form-data +Authorization: Bearer +``` + +| Field | Notes | +|---|---| +| `file` | Required. Must be an `image/*` content type. Max 50 MB. | + +Response: + +```json +{ + "id": "ab12cd34ef56", + "url": "http://127.0.0.1:8001/assets/ab12cd34ef56", + "content_type": "image/png", + "size_bytes": 20481 +} +``` + +Then reference the URL normally: + +```markdown +![Chart](http://127.0.0.1:8001/assets/ab12cd34ef56) +``` + +Lifecycle and quota: + +- The image counts toward your quota **immediately on upload**, and can trigger eviction + of old unkept artifacts like any other post. +- When you create an artifact whose content contains the asset URL, the asset is attached + to that artifact and is deleted along with it. +- Uploads never referenced by any artifact are swept after 24 hours. +- Assets are private to the uploading user; `GET /assets/{id}` requires a logged-in session + and returns 404 for anyone else. + +## List Artifacts + +Endpoint: + +```http +GET /api/artifacts +``` + +Requires a logged-in browser session. Returns only the current user's artifacts. + +Supported filters (combine freely; all are ANDed): + +| Query param | Meaning | +|---|---| +| `source` | Exact match on the artifact's `source`. | +| `label` | Matches any of the artifact's `tags`. | +| `day` | Exact day, `YYYY-MM-DD`. | +| `kind` | Exact artifact kind. | +| `q` | Case-insensitive title fragment. | + +`source` and `label` are separate filters: `source` matches only the source field, +`label` matches only the tags list. + +Examples: + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?source=hermes-research" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?kind=markdown&q=brief" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?day=2026-06-10&label=daily" +``` + +## Get Artifact Metadata + +Endpoint: + +```http +GET /api/artifacts/{id} +``` + +Example: + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" +``` + +## Update Artifact Metadata + +Endpoint: + +```http +PATCH /api/artifacts/{id} +Content-Type: application/json +Authorization: Bearer +``` + +Editable fields: + +| Field | Notes | +|---|---| +| `title` | Must not be blank. | +| `tags` | Array or comma-separated string. | +| `tag_colors` | Object mapping tag name → `#RRGGBB`. Updates the global colour registry. | +| `source_prompt` | String. | +| `source_type` | `agent`, `person`, or `system`. | +| `source_name` | String. | +| `source_id` | String. | +| `metadata` | Object. Replaces existing metadata. | + +You can only PATCH artifacts your own token owns; others return `404`. + +Not editable yet: + +| Field | Reason | +|---|---| +| `content` | Content replacement/revisions are intentionally deferred. | +| `source` | Affects storage layout and feed identity. | +| `kind` | Affects renderer and storage behavior. | +| `day` | Affects time-ordered feed and storage layout. | + +PowerShell example: + +```powershell +$body = @{ + title = "Morning Brief - Reviewed" + tags = @("brief", "daily", "reviewed") + metadata = @{ + model = "gpt-5.5" + run_id = "brief-001" + reviewed = $true + } +} | ConvertTo-Json -Depth 5 + +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" ` + -Method Patch ` + -Headers @{ Authorization = "Bearer " } ` + -ContentType "application/json" ` + -Body $body +``` + +## Rendered And Raw Content + +Rendered browser view: + +```http +GET /a/{id} +``` + +Raw/source view: + +```http +GET /a/{id}?raw=1 +``` + +Raw currently exists for: + +| Kind | Raw content | +|---|---| +| `markdown` | Preserved `.md` source. | +| `html_full` | Stored HTML file served as text. | + +If raw content is unavailable, Daybook returns a small HTML fallback page. + +## Capabilities + +Endpoint: + +```http +GET /api/capabilities +``` + +Example response: + +```json +{ + "artifact_kinds": ["html_fragment", "html_full", "markdown", "deck", "image", "pdf", "binary"], + "filters": ["source", "label", "day", "kind", "q"], + "views": ["rendered", "raw"], + "raw_supported_for": ["markdown", "html_full"], + "source_types": ["agent", "person", "system"], + "auth": "bearer_token" +} +``` + +Agents should call this endpoint if they need to adapt to the current Daybook instance, or +simply to check the instance is reachable and their token is valid. Unlike the other read +endpoints, it accepts a bearer token as well as a browser session: + +```bash +curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/capabilities +``` + +## Current MVP Constraints + +- Each user has a personal ingest API token; there is no shared token. +- Reader/browser access requires login; feeds are private per user. +- Content modification/revisions are deferred. +- Office document conversion is not implemented. +- Manual browser upload/drag-and-drop is deferred. +- Pagination/cursors are not implemented yet. diff --git a/docs/llm_posting_helper.md b/docs/llm_posting_helper.md new file mode 100644 index 0000000..cd8cf1d --- /dev/null +++ b/docs/llm_posting_helper.md @@ -0,0 +1,108 @@ +# LLM Posting Helper + +Agents should publish to Daybook with `skills/daybook-posting/scripts/daybook_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests. + +The project also keeps `scripts/daybook_post.py` as a compatibility wrapper. + +The helper is intentionally zero-dependency and uses only Python's standard library. It works on Windows and Linux. + +## Configuration + +```text +DAYBOOK_URL=http://127.0.0.1:8001 +DAYBOOK_TOKEN=your-personal-api-token +DAYBOOK_SOURCE=agent +``` + +`DAYBOOK_TOKEN` is a personal per-user token, from Settings → Account in the app — not a token shared across users. Override these per environment. + +## Commands + +Check the instance: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --check +``` + +Post Markdown from a file: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report +``` + +Post Markdown from stdin: + +```bash +cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" +``` + +Post a deck: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deployment Plan" --content-file deck.html +``` + +`deck.html` must contain top-level `
...
` blocks. + +Post an image/PDF/binary file: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram" +python skills/daybook-posting/scripts/daybook_post.py --file report.pdf --title "Generated Report" +``` + +Render CSV/JSON/code as Markdown today: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --file results.csv --as-markdown --title "Results CSV" +``` + +Dry run: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Draft" --content-file report.md --dry-run +``` + +The artifact URL is printed as the final stdout line. + +## Validation Guardrails + +The helper validates common LLM mistakes before sending: + +| Kind/input | Validation | +|---|---| +| `deck` | Must contain `=5.0,<6.0 +djangorestframework>=3.15,<4.0 +Markdown>=3.6,<4.0 +gunicorn>=23.0,<24.0 +whitenoise>=6.7,<7.0 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..e681cd0 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,28 @@ +# Daybook Posting Helper + +`scripts/daybook_post.py` is a compatibility wrapper for the canonical zero-dependency helper at `skills/daybook-posting/scripts/daybook_post.py`. + +The helper is for agents and scripts that need to publish artifacts to Daybook without hand-crafting JSON, curl, or multipart requests. + +Configuration: + +```text +DAYBOOK_URL=http://127.0.0.1:8001 +DAYBOOK_TOKEN=your-personal-api-token +DAYBOOK_SOURCE=agent +``` + +`DAYBOOK_TOKEN` is a personal per-user token from Settings → Account in the app, not shared across users. + +Examples: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --check +python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report +cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" +python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Plan" --content-file deck.html +python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram" +python skills/daybook-posting/scripts/daybook_post.py --file results.csv --as-markdown --title "Results CSV" +``` + +The artifact browser URL is printed as the final stdout line. diff --git a/scripts/daybook_post.py b/scripts/daybook_post.py new file mode 100644 index 0000000..37b8c9b --- /dev/null +++ b/scripts/daybook_post.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for the Daybook posting helper. + +The canonical helper lives inside the self-contained skill folder: +skills/daybook-posting/scripts/daybook_post.py +""" + +import runpy +from pathlib import Path + + +HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py" + + +if __name__ == "__main__": + runpy.run_path(str(HELPER), run_name="__main__") diff --git a/scripts/seed_demo_artifacts.py b/scripts/seed_demo_artifacts.py new file mode 100644 index 0000000..dba250f --- /dev/null +++ b/scripts/seed_demo_artifacts.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Post one demo artifact of every kind, tagged, dated today. + +Posts through the real ingest API using the existing daybook_post.py helper, +so seeded data goes through the same normalization as real agent output. + +Usage (from project root, with the dev server running): + python scripts/seed_demo_artifacts.py +""" + +import subprocess +import sys +import tempfile +from pathlib import Path + +HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py" +PERSONA = "seed-demo" +TAGS = "seed-demo,demo" + + +def run_post(args): + result = subprocess.run([sys.executable, str(HELPER), *args], capture_output=True, text=True) + print(result.stdout.strip()) + if result.returncode != 0: + print(result.stderr.strip(), file=sys.stderr) + return result.returncode + + +def post_text(kind, title, content, extra_tag): + with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8") as tmp: + tmp.write(content) + content_path = tmp.name + return run_post([ + "--kind", kind, + "--title", title, + "--persona", PERSONA, + "--tags", f"{TAGS},{extra_tag}", + "--content-file", content_path, + ]) + + +def post_file(title, file_path, extra_tag): + return run_post([ + "--file", str(file_path), + "--title", title, + "--persona", PERSONA, + "--tags", f"{TAGS},{extra_tag}", + ]) + + +def main(): + exit_codes = [] + + exit_codes.append(post_text( + "markdown", + "Seed: Markdown Sample", + "# Markdown Sample\n\nThis is a **seed** artifact demonstrating markdown rendering.\n\n- item one\n- item two\n", + "markdown", + )) + + exit_codes.append(post_text( + "html_fragment", + "Seed: HTML Fragment Sample", + "

HTML Fragment

This is a seed HTML fragment artifact.

", + "html-fragment", + )) + + exit_codes.append(post_text( + "html_full", + "Seed: Full HTML Sample", + "Seed Full HTML" + "

Full HTML Doc

Seed artifact.

", + "html-full", + )) + + exit_codes.append(post_text( + "deck", + "Seed: Deck Sample", + "

Slide One

Seed deck artifact.

" + "

Slide Two

More content.

", + "deck", + )) + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_dir = Path(tmp_dir) + + png_path = tmp_dir / "seed_pixel.png" + png_path.write_bytes(bytes.fromhex( + "89504e470d0a1a0a0000000d494844520000000100000001080600000" + "01f15c4890000000a49444154789c6360000002000155e59a1a0000000049454e44ae426082" + )) + exit_codes.append(post_file("Seed: Image Sample", png_path, "image")) + + pdf_path = tmp_dir / "seed_doc.pdf" + pdf_path.write_bytes( + b"%PDF-1.1\n1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"trailer<>\n" + ) + exit_codes.append(post_file("Seed: PDF Sample", pdf_path, "pdf")) + + bin_path = tmp_dir / "seed_data.bin" + bin_path.write_bytes(b"\x00\x01\x02\x03seed-binary-data\xff\xfe") + exit_codes.append(post_file("Seed: Binary Sample", bin_path, "binary")) + + return 1 if any(exit_codes) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/daybook-posting/SKILL.md b/skills/daybook-posting/SKILL.md new file mode 100644 index 0000000..d3fa546 --- /dev/null +++ b/skills/daybook-posting/SKILL.md @@ -0,0 +1,73 @@ +--- +name: daybook-posting +description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to Daybook and returns a URL. +--- + +# Daybook Posting + +Daybook is a private artifact journal for completed agent outputs. It stores, indexes, and displays artifacts for human review. It does not execute commands, run workflows, or continue conversations. + +Always use the helper script. Do not hand-build curl, JSON, PowerShell multipart, or inline shell content. + +From the project root: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --check +python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md +cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" +python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram" +``` + +The project also keeps a compatibility wrapper at `scripts/daybook_post.py`, but the canonical helper is bundled inside this skill. + +The artifact URL is printed as the final stdout line. + +## Configuration + +Use environment variables: + +```text +DAYBOOK_URL=http://127.0.0.1:8001 +DAYBOOK_TOKEN=your-personal-api-token +DAYBOOK_SOURCE=agent +``` + +`DAYBOOK_TOKEN` is your personal per-user API token, from Settings → Account in the app (not a token shared by everyone). Artifacts posted with your token land only in your feed. + +## Kind Cheat Sheet + +| Kind | Use for | Content shape | +|---|---|---| +| `markdown` | Reports, briefs, notes | Markdown text. | +| `html_fragment` | Themed HTML reports/cards/tables | Body-only HTML. No `html/head/body`. | +| `html_full` | Complete custom pages/frameworks | Full HTML document with doctype/html/head/body. | +| `deck` | Simple slide-like artifact | Top-level `
...
` blocks. | + +For images, PDFs, SVGs, and other files, use `--file`; the helper infers multipart and content type. + +## Images Inside Content + +To embed an image *within* a markdown or HTML artifact (rather than posting it as its own +artifact), upload it first and reference the returned URL: + +```bash +# 1. Upload -> prints the URL as the last line +python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png + +# 2. Reference that URL in your markdown, then post as usual +# ![chart](http://127.0.0.1:8001/assets/ab12cd34ef56) +python skills/daybook-posting/scripts/daybook_post.py report.md +``` + +The image is linked to the artifact that references it and is deleted along with it. +Unreferenced uploads are cleaned up after 24 hours. + +For CSV, JSON, or code that should render today, use `--file path --as-markdown` to post it as a Markdown fenced code block. Without `--as-markdown`, these upload as downloadable binary files. + +## Rules + +- Run `--check` first if unsure Daybook is reachable. +- Use a stable `DAYBOOK_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact). +- Use `--tags a,b,c` for filtering. +- Use `--meta run_id=... --meta model=...` for machine-readable context. +- Never inline multiline content into the shell command; use a file or stdin. diff --git a/skills/daybook-posting/scripts/daybook_post.py b/skills/daybook-posting/scripts/daybook_post.py new file mode 100644 index 0000000..cc8c9a8 --- /dev/null +++ b/skills/daybook-posting/scripts/daybook_post.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +"""Post artifacts to Daybook without shell-specific JSON or multipart quoting. + +Zero third-party dependencies. Content comes from a file or stdin so agents do not +need to inline multiline JSON/HTML/Markdown in shell commands. +""" + +import argparse +import json +import mimetypes +import os +import re +import sys +import uuid +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin +from urllib.request import Request, urlopen + + +TEXT_KINDS = {"markdown", "html_fragment", "html_full", "deck"} +TEXT_SUFFIXES = {".md", ".markdown", ".txt", ".html", ".htm"} +DEFAULT_URL = "http://127.0.0.1:8001" +DEFAULT_SOURCE = "agent" + + +def main(): + args = parse_args() + base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/") + # DAYBOOK_TOKEN is your personal per-user token (Settings -> Account in the app); + # there is no shared fallback token, so posting without it cannot work. + token = os.environ.get("DAYBOOK_TOKEN", "") + source = args.source or os.environ.get("DAYBOOK_SOURCE", DEFAULT_SOURCE) + + if args.check: + try: + response = request_json("GET", f"{base_url}/api/capabilities", token) + except (HTTPError, URLError) as exc: + print_http_error(exc) + return 1 + print(json.dumps(response, indent=2)) + return 0 + + if not token: + print( + "error: DAYBOOK_TOKEN is not set. Get your personal API token from " + f"{base_url}/settings/account/ and set DAYBOOK_TOKEN.", + file=sys.stderr, + ) + return 2 + + if args.upload_image: + try: + response = upload_asset(base_url, token, Path(args.upload_image)) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except (HTTPError, URLError) as exc: + print_http_error(exc) + return 1 + print(json.dumps(response, indent=2)) + print(response.get("url", "")) + return 0 + + try: + if args.path: + resolve_auto_path(args) + payload = build_payload(args, source) + validate_payload(payload, args) + if args.dry_run: + print_dry_run(base_url, token, payload, args) + return 0 + response = post_payload(base_url, token, payload, args) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except (HTTPError, URLError) as exc: + print_http_error(exc) + return 1 + + print(json.dumps(response, indent=2)) + print(response.get("url", "")) + return 0 + + +def parse_args(): + parser = argparse.ArgumentParser(description="Publish an artifact to Daybook.") + parser.add_argument("path", nargs="?", help="Auto mode: one file path. Kind, content-type, and title are inferred. The simplest way to post.") + parser.add_argument("--kind", choices=sorted(TEXT_KINDS), help="Text artifact kind. Omit when using --file or a positional path.") + parser.add_argument("--title", help="Artifact title. Required unless --check is used.") + parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to DAYBOOK_SOURCE or 'agent'.") + parser.add_argument("--content-file", help="Read text artifact content from this file.") + parser.add_argument("--file", help="Upload this file as image/pdf/binary, or render as markdown with --as-markdown.") + parser.add_argument("--tags", help="Comma-separated tags.") + parser.add_argument("--theme", help="Theme for markdown/html_fragment. Example: report, tasks, bootstrap.") + parser.add_argument("--meta", action="append", default=[], help="Metadata key=value. Repeatable.") + parser.add_argument("--meta-json", help="Metadata JSON object. Merged with --meta values.") + parser.add_argument("--source-name", help="Human-readable source name. Defaults to --source.") + parser.add_argument("--source-id", default="", help="Stable source identifier.") + parser.add_argument("--source-prompt", default="", help="Prompt/instruction that produced the artifact.") + parser.add_argument("--source-type", choices=["agent", "person", "system"], default="agent") + parser.add_argument("--as-markdown", action="store_true", help="Wrap --file content in a Markdown fenced code block instead of uploading it.") + parser.add_argument("--upload-image", help="Upload an image for embedding in artifact content. Prints the URL to reference in markdown/HTML.") + parser.add_argument("--check", action="store_true", help="Fetch /api/capabilities and exit.") + parser.add_argument("--dry-run", action="store_true", help="Print request details without posting.") + args = parser.parse_args() + + if args.check or args.upload_image: + return args + + if args.path: + if args.file or args.content_file: + parser.error("provide a positional path OR --file/--content-file, not both") + return args # kind and title are inferred in resolve_auto_path + + if not args.title: + parser.error("--title is required (or pass a positional path to infer it, or use --check)") + if args.file and args.content_file: + parser.error("use either --file or --content-file, not both") + if args.file and args.kind and not args.as_markdown: + parser.error("omit --kind when using --file unless --as-markdown is set") + if not args.file and not args.kind: + parser.error("--kind is required for text artifacts") + return args + + +def resolve_auto_path(args): + """Auto mode: turn a single positional path into the equivalent explicit args. + + Text files (.md/.html/.txt) become a text artifact with a sniffed kind; every + other file is uploaded as multipart with an inferred content type. Title is + inferred from the content or the filename when not given. This is the path that + keeps agents from ever building JSON or choosing a kind by hand. + """ + path = Path(args.path) + if not path.is_file(): + raise ValueError(f"path does not exist: {path}") + + suffix = path.suffix.lower() + if suffix in TEXT_SUFFIXES and not args.as_markdown: + text = path.read_text(encoding="utf-8") + kind = sniff_text_kind(suffix, text) + args.content_file = str(path) + args.kind = kind + if not args.title: + args.title = derive_title(path, kind, text) + else: + # image / pdf / binary -> multipart upload; code/data -> markdown wrap if asked + args.file = str(path) + args.kind = None + if not args.title: + args.title = humanize(path.stem) + return args + + +def sniff_text_kind(suffix, text): + if suffix in (".md", ".markdown", ".txt"): + return "markdown" + lower = text.lower() + if "]*>(.*?)", text, re.IGNORECASE | re.DOTALL) + if match and match.group(1).strip(): + return match.group(1).strip() + match = re.search(r"]*>(.*?)", text, re.IGNORECASE | re.DOTALL) + if match: + inner = re.sub(r"<[^>]+>", "", match.group(1)).strip() + if inner: + return inner + return humanize(path.stem) + + +def humanize(stem): + cleaned = stem.replace("-", " ").replace("_", " ").strip() + return cleaned.title() if cleaned else stem + + +def build_payload(args, source): + metadata = parse_metadata(args) + common = { + "source": source, + "title": args.title, + "tags": normalize_tags(args.tags), + "source_type": args.source_type, + "source_name": args.source_name or source, + "source_id": args.source_id, + "source_prompt": args.source_prompt, + "metadata": metadata, + } + + if args.file and not args.as_markdown: + file_path = Path(args.file) + if not file_path.is_file(): + raise ValueError(f"upload file does not exist: {file_path}") + return {**common, "upload_path": file_path} + + kind = args.kind or "markdown" + if args.file and args.as_markdown: + file_path = Path(args.file) + if not file_path.is_file(): + raise ValueError(f"content file does not exist: {file_path}") + content = markdown_from_file(file_path) + kind = "markdown" + else: + content = read_text_content(args.content_file) + + payload = {**common, "kind": kind, "content": content} + if args.theme: + payload["theme"] = args.theme + return payload + + +def parse_metadata(args): + metadata = {} + if args.meta_json: + try: + metadata = json.loads(args.meta_json) + except json.JSONDecodeError as exc: + raise ValueError(f"--meta-json is invalid JSON: {exc}") from exc + if not isinstance(metadata, dict): + raise ValueError("--meta-json must be a JSON object") + for item in args.meta: + if "=" not in item: + raise ValueError(f"--meta must be key=value, got: {item}") + key, value = item.split("=", 1) + if not key: + raise ValueError("--meta key cannot be blank") + metadata[key] = value + return metadata + + +def normalize_tags(tags): + if not tags: + return [] + return [tag.strip() for tag in tags.split(",") if tag.strip()] + + +def read_text_content(content_file): + if content_file: + path = Path(content_file) + if not path.is_file(): + raise ValueError(f"content file does not exist: {path}") + return path.read_text(encoding="utf-8") + if sys.stdin.isatty(): + raise ValueError("provide --content-file, --file, or pipe content on stdin") + return sys.stdin.read() + + +def markdown_from_file(path): + content = path.read_text(encoding="utf-8") + language = language_for_suffix(path.suffix) + return f"# {path.name}\n\n```{language}\n{content}\n```\n" + + +def language_for_suffix(suffix): + return { + ".csv": "csv", + ".json": "json", + ".js": "javascript", + ".ts": "typescript", + ".py": "python", + ".ps1": "powershell", + ".sh": "bash", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".c": "c", + ".h": "c", + ".hpp": "cpp", + ".html": "html", + ".css": "css", + ".xml": "xml", + ".svg": "xml", + ".md": "markdown", + ".txt": "text", + }.get(suffix.lower(), "text") + + +def validate_payload(payload, args): + if "upload_path" in payload: + return + kind = payload["kind"] + content_lower = payload["content"].lower() + if kind == "deck" and "... blocks, not Markdown or unwrapped headings") + if kind == "html_fragment" and (" (and ideally )") + if kind == "markdown" and not payload["content"].strip(): + raise ValueError("markdown content cannot be blank") + + +def print_dry_run(base_url, token, payload, args): + target = f"{base_url}/api/artifacts" + print(f"POST {target}") + print(f"Authorization: Bearer {mask(token)}") + if "upload_path" in payload: + path = payload["upload_path"] + print("Content-Type: multipart/form-data") + print(f"file: {path} ({content_type_for(path)})") + printable = {key: value for key, value in payload.items() if key != "upload_path"} + print(json.dumps(printable, indent=2, default=str)) + else: + print("Content-Type: application/json") + printable = dict(payload) + printable["content"] = f"" + print(json.dumps(printable, indent=2)) + + +def mask(token): + if len(token) <= 8: + return "***" + return f"{token[:4]}...{token[-4:]}" + + +def post_payload(base_url, token, payload, args): + url = f"{base_url}/api/artifacts" + if "upload_path" in payload: + body, content_type = multipart_body(payload) + request = Request(url, data=body, method="POST") + request.add_header("Content-Type", content_type) + else: + body = json.dumps(payload).encode("utf-8") + request = Request(url, data=body, method="POST") + request.add_header("Content-Type", "application/json") + request.add_header("Authorization", f"Bearer {token}") + request.add_header("Accept", "application/json") + with urlopen(request, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + + +def multipart_body(payload): + boundary = f"----daybook-{uuid.uuid4().hex}" + chunks = [] + upload_path = payload["upload_path"] + for key, value in payload.items(): + if key == "upload_path": + continue + if key == "metadata": + if not value: + continue + value = json.dumps(value) + elif key == "tags": + if not value: + continue + value = ",".join(value) + elif value in (None, ""): + continue + chunks.append(f"--{boundary}\r\n".encode("utf-8")) + chunks.append(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode("utf-8")) + chunks.append(str(value).encode("utf-8")) + chunks.append(b"\r\n") + + chunks.append(f"--{boundary}\r\n".encode("utf-8")) + chunks.append( + f'Content-Disposition: form-data; name="file"; filename="{upload_path.name}"\r\n'.encode("utf-8") + ) + chunks.append(f"Content-Type: {content_type_for(upload_path)}\r\n\r\n".encode("utf-8")) + chunks.append(upload_path.read_bytes()) + chunks.append(b"\r\n") + chunks.append(f"--{boundary}--\r\n".encode("utf-8")) + return b"".join(chunks), f"multipart/form-data; boundary={boundary}" + + +def content_type_for(path): + suffix = path.suffix.lower() + if suffix == ".svg": + return "image/svg+xml" + if suffix == ".pdf": + return "application/pdf" + return mimetypes.guess_type(path.name)[0] or "application/octet-stream" + + +def upload_asset(base_url, token, path): + """Upload an image and return its Daybook URL for embedding in content.""" + if not path.is_file(): + raise ValueError(f"image file does not exist: {path}") + + boundary = f"----daybook-{uuid.uuid4().hex}" + chunks = [ + f"--{boundary}\r\n".encode("utf-8"), + f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode("utf-8"), + f"Content-Type: {content_type_for(path)}\r\n\r\n".encode("utf-8"), + path.read_bytes(), + b"\r\n", + f"--{boundary}--\r\n".encode("utf-8"), + ] + request = Request(f"{base_url}/api/assets", data=b"".join(chunks), method="POST") + request.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") + request.add_header("Authorization", f"Bearer {token}") + request.add_header("Accept", "application/json") + with urlopen(request, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + + +def request_json(method, url, token=""): + request = Request(url, method=method) + request.add_header("Accept", "application/json") + if token: + request.add_header("Authorization", f"Bearer {token}") + with urlopen(request, timeout=15) as response: + return json.loads(response.read().decode("utf-8")) + + +def print_http_error(exc): + if isinstance(exc, HTTPError): + body = exc.read().decode("utf-8", errors="replace") + print(f"error: HTTP {exc.code} {exc.reason}", file=sys.stderr) + if body: + print(body, file=sys.stderr) + else: + print(f"error: {exc}", file=sys.stderr) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/static/daybook.css b/static/daybook.css new file mode 100644 index 0000000..42885b5 --- /dev/null +++ b/static/daybook.css @@ -0,0 +1,965 @@ +:root { + --pane-bar-height: 2.75rem; + --app-bg: #f6f7f9; + --surface: #ffffff; + --surface-alt: #f8f9fb; + --pane-bar-bg: #ffffff; + --grid-line: #dde1e7; + --text: #2f343d; + --muted: #7b8491; + --selected-day-bg: #3d4552; + --day-tile-bg: #eef0f4; + --selected-artifact-bg: #eaf3ff; + --selected-artifact-rail: #dcecff; + --control-border: #d9dee6; + color-scheme: light; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--app-bg); + color: var(--text); +} + +html, +body { + height: 100%; + overflow: hidden; +} + +body { + display: flex; + flex-direction: column; + margin: 0; +} + +.topbar { + align-items: center; + background: var(--surface-alt); + border-bottom: 1px solid var(--grid-line); + color: var(--text); + display: grid; + flex: 0 0 auto; + gap: 0; + grid-template-columns: max-content 1fr max-content; + padding: 0; +} + +.topbar-actions { + align-items: center; + display: flex; + gap: 0.4rem; + padding: 0.32rem 0.8rem; + position: relative; +} + +.icon-button { + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: var(--muted); + cursor: pointer; + font-size: 0.85rem; + padding: 0.4rem 0.6rem; +} + +.icon-button:hover { + background: var(--surface); + border-color: var(--control-border); +} + +.user-menu-dropdown { + background: var(--surface); + border: 1px solid var(--control-border); + border-radius: 8px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12); + display: flex; + flex-direction: column; + min-width: 10rem; + position: absolute; + right: 0.8rem; + top: calc(100% + 4px); + z-index: 20; +} + +.user-menu-dropdown[hidden] { + display: none; +} + +.user-menu-dropdown a, +.user-menu-dropdown button { + background: transparent; + border: 0; + color: var(--text); + cursor: pointer; + font-size: 0.85rem; + padding: 0.5rem 0.8rem; + text-align: left; + text-decoration: none; + width: 100%; +} + +.user-menu-dropdown a:hover, +.user-menu-dropdown button:hover { + background: var(--surface-alt); +} + +.user-menu-dropdown form { + margin: 0; +} + +.brandline { + align-items: center; + display: flex; + gap: 0.55rem; + padding: 0.38rem 0.8rem; +} + +.brand-link { + color: inherit; + text-decoration: none; +} + +.brand-link:hover { + opacity: 0.75; +} + +.eyebrow { + color: var(--muted); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.14em; + margin: 0 0 0.18rem; + text-transform: uppercase; +} + +h1 { + font-size: clamp(1.2rem, 2vw, 1.75rem); + line-height: 1; + margin: 0; +} + +.toolbar { + align-items: center; + align-self: stretch; + background: var(--surface-alt); + display: flex; + gap: 0.45rem; + min-width: 0; + padding: 0.32rem 0.6rem; +} + +.filter { + align-items: end; + display: flex; + gap: 0.35rem; +} + +.filter label { + color: var(--muted); + display: grid; + font-size: 0.68rem; + gap: 0.08rem; +} + +.filter select, +.filter input { + background: var(--surface); + border: 1px solid var(--control-border); + border-radius: 4px; + color: var(--text); + font: inherit; + padding: 0.32rem 0.62rem; +} + +.filter input { + width: 9rem; +} + +.filter button { + background: #3d4552; + border: 1px solid #343b46; + border-radius: 4px; + color: #ffffff; + cursor: pointer; + font: inherit; + font-weight: 700; + padding: 0.32rem 0.7rem; +} + +.view-toggle { + background: #f0f2f5; + border: 1px solid var(--control-border); + border-radius: 4px; + display: flex; + padding: 0.12rem; +} + +.view-toggle a { + border-radius: 3px; + color: var(--text); + font-size: 0.76rem; + padding: 0.3rem 0.58rem; + text-decoration: none; +} + +.view-toggle a.selected { + background: #3d4552; + color: #ffffff; +} + +.feed { + --artifacts-width: 280px; + --days-width: 144px; + display: grid; + flex: 1 1 auto; + grid-template-columns: var(--days-width) 1px var(--artifacts-width) 1px minmax(0, 1fr); + min-height: 0; + overflow: hidden; +} + +.source-sidebar, +.artifact-sidebar, +.preview-pane { + min-width: 0; + padding: 0; +} + +.source-sidebar, +.artifact-sidebar { + min-height: 0; + overflow-x: hidden; + scrollbar-color: transparent transparent; + scrollbar-width: none; +} + +.source-sidebar { + display: flex; + flex-direction: column; + overflow-y: hidden; +} + +.artifact-sidebar { + display: flex; + flex-direction: column; + overflow-y: hidden; + padding: 0; +} + +.source-list, +.artifact-list { + min-height: 0; + overflow-x: hidden; + overflow-y: auto; +} + +/* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */ +.source-list { + scrollbar-color: transparent transparent; + scrollbar-width: none; +} + +.source-sidebar:hover .source-list { + scrollbar-color: rgba(61, 69, 82, 0.35) transparent; + scrollbar-width: thin; +} + +.source-list::-webkit-scrollbar { + width: 0; +} + +.source-sidebar:hover .source-list::-webkit-scrollbar { + width: 6px; +} + +/* Artifact list: scrollbar always visible so it doesn't jump on hover. */ +.artifact-list { + scrollbar-color: rgba(61, 69, 82, 0.35) transparent; + scrollbar-width: thin; +} + +.artifact-list::-webkit-scrollbar { + width: 8px; +} + +.source-list::-webkit-scrollbar-thumb, +.artifact-list::-webkit-scrollbar-thumb { + background: rgba(61, 69, 82, 0.35); + border-radius: 999px; +} + +.source-list::-webkit-scrollbar-track, +.artifact-list::-webkit-scrollbar-track { + background: transparent; +} + +.source-sidebar { + background: var(--surface-alt); +} + +.artifact-sidebar { + background: var(--surface); +} + +.preview-pane { + align-items: stretch; + display: flex; + flex-direction: column; + justify-content: flex-start; + min-height: 0; + padding: 0; +} + +.splitter { + background: var(--grid-line); + cursor: col-resize; + position: relative; + touch-action: none; +} + +.splitter::after { + background: transparent; + bottom: 0; + content: ""; + left: -4px; + position: absolute; + right: -4px; + top: 0; +} + +.splitter:hover, +.is-resizing .splitter { + background: #b8c0cc; +} + +.is-resizing { + cursor: col-resize; + user-select: none; +} + +.source-sidebar h2, +.pane-heading h2 { + color: var(--text); + font-size: 0.68rem; + letter-spacing: 0.1em; + margin: 0; + text-transform: uppercase; +} + +.source-list { + display: grid; +} + +.source-list { + align-content: start; + flex: 1 1 auto; + gap: 0.35rem; + padding: 0.55rem; +} + +.source-link, +.artifact-row { + color: inherit; + text-decoration: none; +} + +.source-link { + align-items: center; + background: var(--day-tile-bg); + border-radius: 6px; + display: flex; + gap: 0.4rem; + justify-content: space-between; + padding: 0.45rem 0.55rem; +} + +.source-link:hover { + background: var(--surface); + box-shadow: none; +} + +.source-link:focus-visible { + outline: 2px solid #8ab4f8; + outline-offset: 2px; +} + +.source-link.selected { + background: var(--selected-day-bg); + color: #ffffff; +} + +.source-link.selected .source-name, +.source-link.selected .source-count { + color: #ffffff; +} + +.source-name { + font-size: 0.8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-count { + color: var(--muted); + flex: 0 0 auto; + font-size: 0.7rem; +} + +.pane-heading span, +.artifact-time { + color: var(--muted); + font-size: 0.7rem; +} + +.day-separator { + align-items: center; + background: var(--surface-alt); + border-bottom: 1px solid var(--grid-line); + color: var(--muted); + display: flex; + font-size: 0.7rem; + font-weight: 700; + justify-content: space-between; + letter-spacing: 0.04em; + padding: 0.32rem 0.55rem; + position: sticky; + text-transform: uppercase; + top: 0; + z-index: 5; +} + +.day-separator-count { + font-weight: 400; +} + +.feed-more { + height: 1px; +} + +.today-badge { + background: #d8f1df; + border-radius: 999px; + color: #245238; + display: inline-block; + font-size: 0.62rem; + font-style: normal; + font-weight: 500; + margin-left: 0.35rem; + padding: 0.08rem 0.38rem; +} + + +.pane-bar { + align-items: baseline; + background: var(--pane-bar-bg); + box-sizing: border-box; + display: flex; + flex: 0 0 var(--pane-bar-height); + gap: 0.55rem; + height: var(--pane-bar-height); + justify-content: space-between; + margin: 0; + min-height: var(--pane-bar-height); + padding: 0 0.65rem; +} + +.pane-heading, +.preview-actions { + border-bottom: 1px solid var(--grid-line); +} + +.source-sidebar .pane-bar, +.artifact-sidebar .pane-bar { + align-items: center; + justify-content: center; + text-align: center; +} + +.artifact-sidebar .pane-heading { + flex-direction: column; + gap: 0.04rem; +} + +.artifact-list { + align-content: start; + flex: 1 1 auto; +} + +.artifact-row { + align-items: stretch; + background: var(--surface); + border: 0; + border-bottom: 1px solid var(--grid-line); + border-radius: 0; + display: grid; + gap: 0; + grid-template-columns: 3.15rem minmax(0, 1fr) 7rem; + min-height: 2.7rem; + overflow: hidden; + padding: 0; +} + +.artifact-row:hover { + background: #f6f9fd; + box-shadow: none; +} + +.artifact-row:focus-visible { + outline: 0; + box-shadow: inset 0 0 0 1px #8ab4f8; +} + +.artifact-row.selected { + background: var(--selected-artifact-bg); + color: var(--text); +} + +.artifact-row.selected .artifact-time { + background: var(--selected-artifact-rail); +} + +.artifact-row.selected .artifact-title, +.artifact-row.selected .artifact-time { + color: var(--text); +} + +.artifact-row-main { + align-items: center; + border-right: 1px solid var(--grid-line); + display: flex; + flex-wrap: wrap; + gap: 0.32rem; + min-width: 0; + padding: 0.42rem 0.55rem; +} + +.artifact-row.unread .artifact-title { + font-weight: 700; +} + +.artifact-row.unread .artifact-source { + color: var(--text); +} + +.artifact-row-main > .artifact-title { + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + color: var(--text); + display: -webkit-box; + font-size: 0.86rem; + overflow: hidden; +} + +.artifact-kind-text { + background: var(--day-tile-bg); + border-radius: 4px; + color: var(--muted); + font-size: 0.68rem; + padding: 0.1rem 0.34rem; + white-space: nowrap; +} + +.kept-badge { + align-items: center; + background: #eaf7ed; + border-radius: 4px; + color: #28583a; + display: inline-flex; + font-size: 0.66rem; + font-weight: 700; + justify-content: center; + line-height: 1; + padding: 0.14rem 0.3rem; + white-space: nowrap; +} + +.kept-badge[hidden] { + display: none; +} + +.artifact-source { + align-items: center; + color: var(--muted); + display: flex; + font-size: 0.72rem; + overflow: hidden; + padding: 0.42rem 0.55rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tag-chips { + display: flex; + flex-wrap: wrap; + gap: 0.24rem; +} + +.tag-chip { + border-radius: 999px; + color: #ffffff; + font-size: 0.66rem; + font-weight: 600; + line-height: 1; + padding: 0.18rem 0.44rem; + white-space: nowrap; +} + +.artifact-time { + align-items: center; + background: #f3f5f7; + border-right: 1px solid var(--grid-line); + display: flex; + justify-content: flex-end; + justify-self: stretch; + line-height: 1; + padding: 0 0.48rem; + text-align: right; + white-space: nowrap; +} + +.empty { + background: var(--surface); + border-radius: 16px; + padding: 1.2rem; +} + +.empty.compact { + padding: 1rem; +} + +.empty.compact h3, +.preview-empty h2 { + margin-top: 0; +} + +.preview-empty { + max-width: 34rem; +} + +kbd { + background: #f0f2f5; + border: 1px solid var(--control-border); + border-radius: 4px; + color: var(--text); + font: 0.85em ui-monospace, SFMono-Regular, Consolas, monospace; + padding: 0.08rem 0.28rem; +} + +.preview-body { + align-items: stretch; + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} + +.preview-body > .preview-empty { + align-self: center; + margin: auto; +} + +.preview-frame-wrap { + align-items: center; + display: flex; + flex: 1; + justify-content: center; + min-height: 0; +} + +.preview-actions { + align-items: center; + background: var(--pane-bar-bg); + display: flex; + flex: 0 0 auto; + gap: 0.75rem; + justify-content: space-between; + justify-content: space-between; +} + +.preview-title { + color: var(--text); + flex: 1 1 auto; + font-size: 0.78rem; + font-weight: 700; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preview-actions .view-toggle, +.preview-actions .artifact-action-links { + flex: 0 0 auto; +} + +.artifact-action-links { + align-items: center; + display: flex; + gap: 0.35rem; +} + +.artifact-action-links a, +.artifact-action-links button { + background: var(--surface); + border: 1px solid var(--control-border); + border-radius: 4px; + color: var(--text); + cursor: pointer; + font: inherit; + font-size: 0.76rem; + line-height: 1; + padding: 0.42rem 0.65rem; + text-decoration: none; +} + +.view-toggle a.disabled, +.artifact-action-links a.disabled { + cursor: not-allowed; + opacity: 0.48; + pointer-events: none; +} + +.artifact-action-links button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.preview-frame { + background: #fff; + border: 0; + height: 100%; + width: 100%; +} + +.preview-image { + background: #fff; + max-height: 100%; + max-width: 100%; + object-fit: contain; +} + +@media (max-width: 720px) { + html, + body { + height: auto; + overflow: auto; + } + + .topbar { + align-items: stretch; + grid-template-columns: 1fr; + } + + .toolbar, + .filter, + .preview-actions { + align-items: stretch; + flex-wrap: wrap; + } + + .feed { + grid-template-columns: 1fr; + overflow: visible; + } + + .splitter { + display: none; + } + + .source-sidebar, + .artifact-sidebar { + border-bottom: 1px solid #d9c9ad; + border-right: 0; + max-height: 32vh; + } +} + +.login-page { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + overflow: auto; +} + +.login-card { + background: var(--surface); + border: 1px solid var(--control-border); + border-radius: 12px; + display: flex; + flex-direction: column; + gap: 0.8rem; + padding: 2rem; + width: 20rem; +} + +.login-card label { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.login-card label span { + color: var(--muted); + font-size: 0.78rem; +} + +.login-card input[type="text"], +.login-card input[type="password"] { + border: 1px solid var(--control-border); + border-radius: 6px; + padding: 0.5rem 0.6rem; +} + +.login-remember { + align-items: center; + flex-direction: row !important; + gap: 0.5rem !important; +} + +.login-error { + color: #c14f4f; + font-size: 0.82rem; +} + +.settings-layout { + display: grid; + flex: 1 1 auto; + grid-template-columns: 14rem 1fr; + min-height: 0; + overflow: auto; +} + +.settings-nav { + background: var(--surface-alt); + border-right: 1px solid var(--grid-line); + display: flex; + flex-direction: column; + padding: 1rem 0.6rem; +} + +.settings-nav a { + border-radius: 6px; + color: var(--text); + padding: 0.5rem 0.7rem; + text-decoration: none; +} + +.settings-nav a:hover { + background: var(--surface); +} + +.settings-nav a.selected { + background: var(--day-tile-bg); + font-weight: 600; +} + +.settings-nav-back { + border-bottom: 1px solid var(--grid-line); + color: var(--muted) !important; + font-size: 0.82rem; + margin-bottom: 0.5rem; + padding-bottom: 0.7rem !important; +} + +.settings-panel { + max-width: 42rem; + padding: 1.6rem 2rem; +} + +.settings-messages { + background: #eaf7ed; + border-radius: 8px; + color: #28583a; + list-style: none; + margin: 0 0 1.2rem; + padding: 0.6rem 0.9rem; +} + +.token-value { + background: var(--surface-alt); + border-radius: 6px; + display: inline-block; + padding: 0.4rem 0.6rem; + word-break: break-all; +} + +.keep-button.kept { + background: #eaf7ed; + border-color: #9bd4ab; + color: #28583a; +} + +.details-panel { + background: var(--surface-alt); + border-bottom: 1px solid var(--grid-line); + flex: 0 0 auto; + max-height: 40%; + overflow-y: auto; + padding: 0.7rem 1rem; +} + +.details-grid { + display: grid; + gap: 0.25rem 1rem; + grid-template-columns: max-content 1fr; + margin: 0; +} + +.details-grid dt { + color: var(--muted); + font-size: 0.72rem; + text-transform: uppercase; +} + +.details-grid dd { + font-size: 0.82rem; + margin: 0; + word-break: break-word; +} + +.usage-bar { + background: var(--day-tile-bg); + border-radius: 999px; + height: 0.75rem; + margin: 0.5rem 0; + overflow: hidden; +} + +.usage-bar-fill { + background: #5aa552; + border-radius: 999px; + height: 100%; + transition: width 0.3s ease; +} + +.usage-bar-fill--warn { + background: #d98b2b; +} + +.usage-bar-fill--full { + background: #c14f4f; +} + +.usage-summary { + font-size: 0.9rem; +} + +.storage-facts { + color: var(--text); + font-size: 0.85rem; + line-height: 1.7; + padding-left: 1.1rem; +} + +.storage-note { + color: var(--muted); + font-size: 0.82rem; + line-height: 1.5; +} + +.storage-warning { + background: #fdecea; + border-radius: 8px; + color: #a3312a; + font-size: 0.85rem; + padding: 0.6rem 0.9rem; +} diff --git a/static/themes/base.css b/static/themes/base.css new file mode 100644 index 0000000..c9d5916 --- /dev/null +++ b/static/themes/base.css @@ -0,0 +1,18 @@ +body.artifact { + box-sizing: border-box; + color: #1d2528; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.65; + margin: 0 auto; + max-width: 900px; + padding: 3rem 1.25rem; +} + +img, +table { + max-width: 100%; +} + +pre { + overflow-x: auto; +} diff --git a/static/themes/deck.css b/static/themes/deck.css new file mode 100644 index 0000000..13b8ee5 --- /dev/null +++ b/static/themes/deck.css @@ -0,0 +1,30 @@ +body.deck { + background: #111827; + color: #f9fafb; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + margin: 0; +} + +.slides { + display: grid; + gap: 2rem; + margin: 0 auto; + max-width: 1100px; + padding: 2rem; +} + +.slides section { + background: linear-gradient(135deg, #1f2937, #111827); + border: 1px solid #374151; + border-radius: 28px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); + min-height: 62vh; + padding: clamp(2rem, 6vw, 5rem); +} + +.slides h1, +.slides h2 { + color: #fbbf24; + font-size: clamp(2rem, 7vw, 5rem); + line-height: 1; +} diff --git a/static/themes/report.css b/static/themes/report.css new file mode 100644 index 0000000..2a37bec --- /dev/null +++ b/static/themes/report.css @@ -0,0 +1,41 @@ +@import url("/static/themes/base.css"); + +body.artifact { + background: #fffaf0; + color: #1e2b2f; + font-family: Georgia, "Times New Roman", serif; + font-size: 1.08rem; +} + +h1, +h2, +h3 { + color: #17313b; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.15; +} + +a { + color: #8a4f16; +} + +blockquote { + border-left: 4px solid #d5a24c; + color: #5b5449; + margin-left: 0; + padding-left: 1rem; +} + +code, +pre { + background: #efe5d2; + border-radius: 8px; +} + +code { + padding: 0.1rem 0.3rem; +} + +pre { + padding: 1rem; +} diff --git a/static/themes/tasks.css b/static/themes/tasks.css new file mode 100644 index 0000000..a8e71fe --- /dev/null +++ b/static/themes/tasks.css @@ -0,0 +1,20 @@ +@import url("/static/themes/base.css"); + +body.artifact { + background: #f7fbff; + color: #17202a; + max-width: 760px; +} + +h1 { + border-bottom: 3px solid #66a7d8; + padding-bottom: 0.5rem; +} + +li { + margin: 0.45rem 0; +} + +input[type="checkbox"] { + transform: translateY(1px) scale(1.1); +} diff --git a/templates/artifacts/_day_groups.html b/templates/artifacts/_day_groups.html new file mode 100644 index 0000000..2bc6182 --- /dev/null +++ b/templates/artifacts/_day_groups.html @@ -0,0 +1,42 @@ +{% for group in day_groups %} + +{% endfor %} +{% if has_more %} +
+{% endif %} diff --git a/templates/artifacts/_feed_toolbar.html b/templates/artifacts/_feed_toolbar.html new file mode 100644 index 0000000..904c23d --- /dev/null +++ b/templates/artifacts/_feed_toolbar.html @@ -0,0 +1,28 @@ +
+ + {% if selected_source %}{% endif %} + {% if selected_artifact %}{% endif %} + + + + +
diff --git a/templates/artifacts/_topbar.html b/templates/artifacts/_topbar.html new file mode 100644 index 0000000..6f13d6d --- /dev/null +++ b/templates/artifacts/_topbar.html @@ -0,0 +1,37 @@ +
+
+

AgentFeed

+
+
+ {% if topbar_toolbar %}{% include topbar_toolbar %}{% endif %} +
+
+ + +
+
+ diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html new file mode 100644 index 0000000..447fec0 --- /dev/null +++ b/templates/artifacts/feed.html @@ -0,0 +1,567 @@ + + + + + + AgentFeed + + + + {% include "artifacts/_topbar.html" with topbar_toolbar="artifacts/_feed_toolbar.html" %} + +
+ + + + + + + + +
+
+
+ {% if selected_artifact %}{{ selected_artifact.title }}{% else %}No artifact loaded{% endif %} +
+ + +
+ + +
+ {% if selected_artifact %} +
+ {% if selected_view == "raw" %} + + {% elif selected_artifact.kind == "image" %} + {{ selected_artifact.title }} + {% elif selected_artifact.kind == "binary" %} +
+

Binary Artifact

+

This artifact type cannot be previewed inline yet.

+

Open or download it.

+
+ {% else %} + + {% endif %} +
+ {% else %} +
+ {% if artifacts %} +

No artifact loaded

+

Press Right Arrow or select an artifact from the middle column to preview it.

+ {% else %} +

No artifacts for this day

+

This day is ready for new agent output.

+ {% endif %} +
+ {% endif %} +
+
+
+ + + + diff --git a/templates/artifacts/login.html b/templates/artifacts/login.html new file mode 100644 index 0000000..b48a1b5 --- /dev/null +++ b/templates/artifacts/login.html @@ -0,0 +1,34 @@ + + + + + + Log in — AgentFeed + + + +
+ +
+ + diff --git a/templates/artifacts/settings_account.html b/templates/artifacts/settings_account.html new file mode 100644 index 0000000..c5cec85 --- /dev/null +++ b/templates/artifacts/settings_account.html @@ -0,0 +1,15 @@ +{% extends "artifacts/settings_base.html" %} + +{% block settings_content %} +

Account

+

Username: {{ request.user.username }}

+ +

API Token

+

Use this token as Authorization: Bearer <token> when posting artifacts.

+

{{ token.key }}

+
+ {% csrf_token %} + + +
+{% endblock %} diff --git a/templates/artifacts/settings_base.html b/templates/artifacts/settings_base.html new file mode 100644 index 0000000..3b205b6 --- /dev/null +++ b/templates/artifacts/settings_base.html @@ -0,0 +1,29 @@ + + + + + + Settings — AgentFeed + + + + {% include "artifacts/_topbar.html" %} + +
+ +
+ {% if messages %} +
    + {% for message in messages %}
  • {{ message }}
  • {% endfor %} +
+ {% endif %} + {% block settings_content %}{% endblock %} +
+
+ + diff --git a/templates/artifacts/settings_demo_data.html b/templates/artifacts/settings_demo_data.html new file mode 100644 index 0000000..4726753 --- /dev/null +++ b/templates/artifacts/settings_demo_data.html @@ -0,0 +1,10 @@ +{% extends "artifacts/settings_base.html" %} + +{% block settings_content %} +

Demo Data

+

Generate one sample artifact of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.

+
+ {% csrf_token %} + +
+{% endblock %} diff --git a/templates/artifacts/settings_storage.html b/templates/artifacts/settings_storage.html new file mode 100644 index 0000000..b511fa0 --- /dev/null +++ b/templates/artifacts/settings_storage.html @@ -0,0 +1,39 @@ +{% extends "artifacts/settings_base.html" %} + +{% block settings_content %} +

Storage

+ +
+
+
+

+ {{ used_bytes|filesizeformat }} of {{ quota_bytes|filesizeformat }} used ({{ percent_used }}%) +

+ +
    +
  • {{ artifact_count }} artifact{{ artifact_count|pluralize }} total
  • +
  • {{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) — never auto-deleted
  • +
+ +

+ When your quota fills, the oldest unkept artifacts are deleted + automatically to make room for new ones. Click Keep on an artifact + to protect it. If the quota is full of kept artifacts, new posts are rejected until + you free space. +

+ + {% if is_superuser %} +

Admin

+
    +
  • Total quota allocated across all users: {{ total_allocated|filesizeformat }}
  • +
  • Free space on the artifact disk: {{ disk_free|filesizeformat }}
  • +
+ {% if total_allocated > disk_free %} +

+ ⚠ Allocated quota exceeds free disk space. Grow the volume before raising quotas, + or usage could fill the actual disk. +

+ {% endif %} +

Per-user quotas are set in the Django admin.

+ {% endif %} +{% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/test_artifacts.py b/tests/api/test_artifacts.py new file mode 100644 index 0000000..a2c7e71 --- /dev/null +++ b/tests/api/test_artifacts.py @@ -0,0 +1,276 @@ +import shutil +import tempfile +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.test import override_settings +from django.urls import reverse +from django.utils import timezone +from rest_framework.authtoken.models import Token +from rest_framework.test import APITestCase + +from artifacts.models import Artifact + +User = get_user_model() + + +class ArtifactAPITestCase(APITestCase): + """Exercises the ingest/list/detail API against a real (temp) filesystem + and a real (test) database, the way an agent posting over HTTP would.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-artifacts-")) + cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root) + cls.settings_override.enable() + + @classmethod + def tearDownClass(cls): + cls.settings_override.disable() + shutil.rmtree(cls.artifacts_root, ignore_errors=True) + super().tearDownClass() + + def setUp(self): + self.user = User.objects.create_user(username="alice", password="pw") + self.token = Token.objects.create(user=self.user) + self.other_user = User.objects.create_user(username="bob", password="pw") + self.other_token = Token.objects.create(user=self.other_user) + + def auth_headers(self, token): + return {"HTTP_AUTHORIZATION": f"Bearer {token.key}"} + + def create_artifact(self, token=None, **overrides): + token = token or self.token + payload = { + "source": "test-agent", + "title": "Hello World", + "kind": "markdown", + "content": "# Hello\n\nBody text.", + } + payload.update(overrides) + return self.client.post( + reverse("artifact-api"), payload, format="json", **self.auth_headers(token) + ) + + # -- capabilities ------------------------------------------------- + + def test_capabilities_requires_auth(self): + response = self.client.get(reverse("capabilities-api")) + self.assertEqual(response.status_code, 403) + + def test_capabilities_with_token(self): + response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token)) + self.assertEqual(response.status_code, 200) + self.assertIn("markdown", response.data["artifact_kinds"]) + + def test_capabilities_lists_supported_methods(self): + response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token)) + methods = response.data["methods"] + self.assertEqual(methods["/api/artifacts"], ["GET", "POST"]) + self.assertEqual(methods["/api/artifacts/{id}"], ["GET", "PATCH", "DELETE"]) + + def test_capabilities_with_session(self): + self.client.force_login(self.user) + response = self.client.get(reverse("capabilities-api")) + self.assertEqual(response.status_code, 200) + + # -- create (POST) -------------------------------------------------- + + def test_create_markdown_artifact(self): + response = self.create_artifact(tags=["demo", "weekly"]) + self.assertEqual(response.status_code, 201) + body = response.data + self.assertEqual(body["source"], "test-agent") + self.assertEqual(body["kind"], "markdown") + self.assertEqual(body["title"], "Hello World") + self.assertEqual([tag["name"] for tag in body["tags"]], ["demo", "weekly"]) + + artifact = Artifact.objects.get(pk=body["id"]) + self.assertEqual(artifact.owner, self.user) + + def test_create_artifact_writes_files_to_disk(self): + response = self.create_artifact() + artifact = Artifact.objects.get(pk=response.data["id"]) + stored_path = self.artifacts_root / artifact.path + self.assertTrue(stored_path.exists()) + # Markdown artifacts also preserve the original .md alongside the rendered .html + self.assertTrue(stored_path.with_suffix(".md").exists()) + + def test_create_artifact_requires_token(self): + response = self.client.post( + reverse("artifact-api"), + {"source": "test-agent", "title": "x", "kind": "markdown", "content": "hi"}, + format="json", + ) + self.assertEqual(response.status_code, 403) + + def test_create_artifact_rejects_invalid_kind(self): + response = self.create_artifact(kind="pdf") + self.assertEqual(response.status_code, 400) + + def test_create_artifact_rejects_missing_fields(self): + response = self.client.post( + reverse("artifact-api"), {"kind": "markdown"}, format="json", **self.auth_headers(self.token) + ) + self.assertEqual(response.status_code, 400) + + # -- list (GET) ------------------------------------------------------- + + def test_list_requires_session_login(self): + response = self.client.get(reverse("artifact-api")) + self.assertEqual(response.status_code, 302) # redirected to login + + def test_list_scoped_to_owner(self): + self.create_artifact() + self.create_artifact(token=self.other_token, title="Not mine") + + self.client.force_login(self.user) + response = self.client.get(reverse("artifact-api")) + self.assertEqual(response.status_code, 200) + self.assertEqual([a["title"] for a in response.data], ["Hello World"]) + + def test_list_filter_by_label(self): + self.create_artifact(tags=["urgent"]) + self.create_artifact(title="Second", tags=["later"]) + + self.client.force_login(self.user) + response = self.client.get(reverse("artifact-api"), {"label": "urgent"}) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], "Hello World") + + def test_list_filter_by_kind(self): + self.create_artifact(kind="markdown") + self.create_artifact(title="Frag", kind="html_fragment", content="

hi

") + + self.client.force_login(self.user) + response = self.client.get(reverse("artifact-api"), {"kind": "html_fragment"}) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], "Frag") + + def test_list_filter_by_query(self): + self.create_artifact(title="Weekly Report") + self.create_artifact(title="Random Notes") + + self.client.force_login(self.user) + response = self.client.get(reverse("artifact-api"), {"q": "weekly"}) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], "Weekly Report") + + def test_list_filter_by_day(self): + self.create_artifact() + today = timezone.localdate().isoformat() + + self.client.force_login(self.user) + response = self.client.get(reverse("artifact-api"), {"day": today}) + self.assertEqual(len(response.data), 1) + + response = self.client.get(reverse("artifact-api"), {"day": "2000-01-01"}) + self.assertEqual(len(response.data), 0) + + # -- detail (GET/PATCH) ----------------------------------------------- + + def test_detail_get_scoped_to_owner(self): + artifact_id = self.create_artifact().data["id"] + + self.client.force_login(self.other_user) + response = self.client.get(reverse("artifact-detail-api", args=[artifact_id])) + self.assertEqual(response.status_code, 404) + + self.client.force_login(self.user) + response = self.client.get(reverse("artifact-detail-api", args=[artifact_id])) + self.assertEqual(response.status_code, 200) + + def test_patch_updates_title_and_tags(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.patch( + reverse("artifact-detail-api", args=[artifact_id]), + {"title": "Updated title", "tags": ["a", "b"]}, + format="json", + **self.auth_headers(self.token), + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["title"], "Updated title") + self.assertEqual([t["name"] for t in response.data["tags"]], ["a", "b"]) + + def test_patch_rejects_blank_title(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.patch( + reverse("artifact-detail-api", args=[artifact_id]), + {"title": ""}, + format="json", + **self.auth_headers(self.token), + ) + self.assertEqual(response.status_code, 400) + + def test_patch_requires_token(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.patch( + reverse("artifact-detail-api", args=[artifact_id]), {"title": "No token"}, format="json" + ) + self.assertEqual(response.status_code, 403) + + def test_patch_cannot_touch_other_users_artifact(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.patch( + reverse("artifact-detail-api", args=[artifact_id]), + {"title": "Hijacked"}, + format="json", + **self.auth_headers(self.other_token), + ) + self.assertEqual(response.status_code, 404) + + # -- delete (DELETE) -------------------------------------------------- + + def test_delete_removes_artifact_and_files(self): + artifact_id = self.create_artifact().data["id"] + artifact = Artifact.objects.get(pk=artifact_id) + stored_path = self.artifacts_root / artifact.path + self.assertTrue(stored_path.exists()) + + response = self.client.delete( + reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + self.assertEqual(response.status_code, 204) + self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists()) + self.assertFalse(stored_path.exists()) + self.assertFalse(stored_path.with_suffix(".md").exists()) + + def test_delete_requires_token(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.delete(reverse("artifact-detail-api", args=[artifact_id])) + self.assertEqual(response.status_code, 403) + self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists()) + + def test_delete_cannot_touch_other_users_artifact(self): + artifact_id = self.create_artifact().data["id"] + response = self.client.delete( + reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.other_token) + ) + self.assertEqual(response.status_code, 404) + self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists()) + + def test_delete_twice_second_call_404s(self): + artifact_id = self.create_artifact().data["id"] + first = self.client.delete( + reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + self.assertEqual(first.status_code, 204) + + second = self.client.delete( + reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + self.assertEqual(second.status_code, 404) + + def test_deleted_artifact_is_gone_from_list_and_detail(self): + artifact_id = self.create_artifact().data["id"] + self.client.delete( + reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token) + ) + + self.client.force_login(self.user) + list_response = self.client.get(reverse("artifact-api")) + self.assertEqual(list_response.data, []) + + detail_response = self.client.get(reverse("artifact-detail-api", args=[artifact_id])) + self.assertEqual(detail_response.status_code, 404)