Refine AgentFeed UI and local workflow

This commit is contained in:
peter 2026-06-11 14:01:16 +01:00
parent 3179f832fc
commit ba9f5eb168
29 changed files with 2986 additions and 69 deletions

19
.dockerignore Normal file
View file

@ -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/

View file

@ -33,11 +33,28 @@ The core product principle is: agents publish content, not files. The server own
- Preserve existing planning decisions unless the user explicitly changes them. - Preserve existing planning decisions unless the user explicitly changes them.
- Avoid adding backwards-compatibility code before anything has shipped. - 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.
- 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 ## Commands
- Install dependencies: `pip install -r requirements.txt` - Install dependencies: `pip install -r requirements.txt`
- Run migrations: `python manage.py migrate` - Run migrations: `python manage.py migrate`
- Start dev server: `python manage.py runserver` - 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` - Django checks: `python manage.py check`
## API Defaults ## API Defaults

28
_archive_/hosts/README.md Normal file
View file

@ -0,0 +1,28 @@
# Daybook Hosts
This directory tracks Daybook deployment targets for fast iteration.
These notes are intentionally app-specific. They should answer: where is Daybook running, how do we update it, how do we verify it, and where does persistent Daybook data live?
They are not a replacement for host administration docs. For the Proxmox NUC, the canonical infrastructure source remains the NUC ops repo:
```text
C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER
```
## Deployment Targets
| Host | Status | Notes |
|---|---|---|
| [NUC Daybook](nuc-daybook.md) | Live | Current local/LAN deployment in Proxmox LXC `128` |
| [Cloud template](cloud-template.md) | Planned | Fill this in when a hosted/cloud instance is created |
## Documentation Rule
When a Daybook deployment changes, update the matching file here and the broader handoff doc if takeover facts changed:
```text
planning/daybook-container-handoff.md
```
When the NUC host/container configuration changes, also update the NUC ops knowledge base.

View file

@ -0,0 +1,85 @@
# Cloud Daybook Host Template
Use this when creating a hosted/cloud Daybook deployment.
Status: planned.
## Summary
| Item | Value |
|---|---|
| Provider | TBD |
| Hostname | TBD |
| Public URL | TBD |
| SSH | TBD |
| OS | TBD |
| App user | TBD |
| Deployment path | TBD |
| Persistent data path | TBD |
| API base URL | TBD |
| TLS | TBD |
| Backup location | TBD |
## Target Runtime
Preferred simple runtime for the first cloud deployment:
```text
Django + Gunicorn in Docker Compose
```
Required persistent state:
```text
deploy/data/db.sqlite3
deploy/data/artifacts_store
```
Do not bake persistent data into the image.
## Environment
Expected production environment values:
```text
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=<hostnames and IPs>
DAYBOOK_API_TOKEN=<write token>
DAYBOOK_DATA_DIR=/data
DATABASE_PATH=/data/db.sqlite3
ARTIFACTS_ROOT=/data/artifacts_store
```
## Deployment Flow
Initial deployment checklist:
1. Provision host.
2. Install Docker and Docker Compose plugin.
3. Copy or clone Daybook source.
4. Create production `.env`.
5. Create persistent `deploy/data` directory.
6. Start with `docker compose -f deploy/docker-compose.yml up -d --build`.
7. Verify `/api/capabilities`.
8. Publish a smoke artifact.
9. Configure backups for `deploy/data`.
## Health Checks
```bash
curl -fsS <api-base-url>/api/capabilities
docker compose -f deploy/docker-compose.yml ps
test -f deploy/data/db.sqlite3
test -d deploy/data/artifacts_store
```
## Open Decisions
| Decision | Options | Choice |
|---|---|---|
| Hosting provider | VPS, home tunnel, managed container host | TBD |
| Domain | Existing domain, subdomain, direct IP | TBD |
| TLS | Reverse proxy, provider load balancer, Tailscale-only | TBD |
| Backups | Host cron, provider snapshots, object storage archive | TBD |
| Database | SQLite for MVP, Postgres later | TBD |
| Reader auth | None for MVP, basic auth, app auth | TBD |

View file

@ -0,0 +1,145 @@
# NUC Daybook Host
Current Daybook deployment target for fast local iteration.
Last checked: 2026-06-11.
## Summary
| Item | Value |
|---|---|
| Proxmox host | `pve`, Intel NUC5i3MYHE, Proxmox VE 9.1.0 / Debian 13 |
| Host LAN IP | `192.168.1.6` |
| Host Tailscale IP | `100.76.55.68` |
| Host SSH | `claude@proxmox.cygnus.internal` or `claude@nuc.cygnus.internal` |
| Container | LXC VMID `128`, hostname `daybook` |
| Container IP | `192.168.1.36/24` |
| App URL | `http://192.168.1.36:8000/` |
| API base URL | `http://192.168.1.36:8000` |
| Deployment path | `/opt/daybook` |
| Persistent data path | `/opt/daybook/deploy/data` |
| Local source tree | `C:\Users\ritte\_PR_DEV_\DEV_WEB_APPS\AGENT_DAYBOOK` |
| NUC ops repo | `C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER` |
Write requests use the Daybook bearer token configured in `/opt/daybook/deploy/.env` as `DAYBOOK_API_TOKEN`.
## Runtime Shape
Daybook runs in Docker Compose inside unprivileged Debian LXC `128` with `nesting=1` enabled.
Compose root:
```text
/opt/daybook
```
Compose file:
```text
/opt/daybook/deploy/docker-compose.yml
```
Persistent state must stay outside the image:
```text
/opt/daybook/deploy/data
```
That directory contains the SQLite database and stored artifacts.
## Fast Update Loop
From the Proxmox host, rebuild the running Daybook service inside CT `128`:
```bash
sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml up -d --build'
```
Before copying source or rebuilding, preserve:
```text
/opt/daybook/deploy/.env
/opt/daybook/deploy/data
```
The current deployment was copied from the local source tree rather than cloned from a remote Git repository.
## Health Checks
From a machine that can reach the LAN:
```bash
curl -fsS http://192.168.1.36:8000/api/capabilities
```
From the Proxmox host:
```bash
sudo pct status 128
sudo pct config 128
sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml ps'
sudo pct exec 128 -- bash -lc 'test -f /opt/daybook/deploy/data/db.sqlite3 && test -d /opt/daybook/deploy/data/artifacts_store'
```
Storage check:
```bash
sudo /sbin/lvs -o vg_name,lv_name,lv_size,data_percent pve/data pve2/data
```
Important: `local-lvm` / `pve/data` was near the alert threshold at last check. New larger VM/CT disks should normally go on `local-lvm2`. Daybook already uses `local-lvm2`.
## Smoke Publish
Use this after ingest, rendering, or deployment changes. Replace the token with the value from `/opt/daybook/deploy/.env` if it changes.
```bash
curl -fsS -X POST http://192.168.1.36:8000/api/artifacts \
-H "Authorization: Bearer $DAYBOOK_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"persona": "deploy-smoke",
"source_type": "agent",
"source_name": "Deployment Smoke Agent",
"kind": "markdown",
"theme": "report",
"title": "Deployment Smoke Test",
"content": "# Deployment Smoke Test\n\nDaybook accepted an artifact.",
"tags": ["deploy", "smoke"],
"metadata": {"run_id": "deploy-smoke-001"}
}'
```
The response should include `id`, `url`, `api_url`, and `raw_url`. Open the returned `url` or refresh the feed.
## Backup Target
Back up this directory:
```text
/opt/daybook/deploy/data
```
Quick host-side backup command:
```bash
sudo pct exec 128 -- bash -lc 'tar -czf /tmp/daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data'
```
For heavy write activity, stop the Compose service before archiving and start it again afterward.
## Related Docs
Broader deployment handoff:
```text
planning/daybook-container-handoff.md
```
NUC infrastructure docs:
```text
C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER\knowledge_base\containers\128-daybook.md
C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER\knowledge_base\daybook\api.md
C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER\knowledge_base\daybook\agent-posting.md
```

View file

@ -5,6 +5,6 @@ from .models import Artifact
@admin.register(Artifact) @admin.register(Artifact)
class ArtifactAdmin(admin.ModelAdmin): class ArtifactAdmin(admin.ModelAdmin):
list_display = ("id", "title", "persona", "kind", "day", "created_at") list_display = ("id", "title", "persona", "source_type", "source_name", "kind", "day", "created_at")
list_filter = ("persona", "kind", "day") list_filter = ("persona", "source_type", "kind", "day")
search_fields = ("id", "title", "persona") search_fields = ("id", "title", "persona", "source_name", "source_id")

View file

@ -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),
]

View file

@ -10,6 +10,10 @@ class Artifact(models.Model):
PDF = "pdf" PDF = "pdf"
BINARY = "binary" BINARY = "binary"
AGENT = "agent"
PERSON = "person"
SYSTEM = "system"
KIND_CHOICES = [ KIND_CHOICES = [
(HTML_FRAGMENT, "HTML fragment"), (HTML_FRAGMENT, "HTML fragment"),
(HTML_FULL, "Full HTML"), (HTML_FULL, "Full HTML"),
@ -20,6 +24,12 @@ class Artifact(models.Model):
(BINARY, "Binary"), (BINARY, "Binary"),
] ]
SOURCE_TYPE_CHOICES = [
(AGENT, "Agent"),
(PERSON, "Person"),
(SYSTEM, "System"),
]
id = models.CharField(max_length=8, primary_key=True) id = models.CharField(max_length=8, primary_key=True)
persona = models.CharField(max_length=80, db_index=True) persona = models.CharField(max_length=80, db_index=True)
title = models.CharField(max_length=200) title = models.CharField(max_length=200)
@ -31,6 +41,10 @@ class Artifact(models.Model):
original_kind = models.CharField(max_length=20) original_kind = models.CharField(max_length=20)
tags = models.JSONField(default=list, blank=True) tags = models.JSONField(default=list, blank=True)
source_prompt = models.TextField(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)
class Meta: class Meta:
ordering = ["-created_at"] ordering = ["-created_at"]

View file

@ -6,5 +6,7 @@ from . import views
urlpatterns = [ urlpatterns = [
path("", views.feed, name="feed"), path("", views.feed, name="feed"),
path("a/<str:artifact_id>", views.artifact_view, name="artifact"), path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
] ]

View file

@ -1,4 +1,5 @@
import html import html
import json
import mimetypes import mimetypes
import secrets import secrets
from pathlib import Path from pathlib import Path
@ -39,6 +40,10 @@ class ArtifactListCreateView(APIView):
artifacts = artifacts.filter(day=day) artifacts = artifacts.filter(day=day)
if tag := request.query_params.get("tag"): if tag := request.query_params.get("tag"):
artifacts = artifacts.filter(tags__contains=[tag]) artifacts = artifacts.filter(tags__contains=[tag])
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]) return Response([serialize_artifact(artifact, request) for artifact in artifacts])
@ -50,7 +55,7 @@ class ArtifactListCreateView(APIView):
artifact = create_binary_artifact(request.data) artifact = create_binary_artifact(request.data)
if isinstance(artifact, Response): if isinstance(artifact, Response):
return artifact return artifact
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201) return Response(serialize_artifact(artifact, request), status=201)
data = request.data data = request.data
kind = data.get("kind") kind = data.get("kind")
@ -62,16 +67,81 @@ class ArtifactListCreateView(APIView):
if missing: if missing:
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400) 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) artifact = create_text_artifact(data, request)
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201) return Response(serialize_artifact(artifact, request), status=201)
class ArtifactDetailView(APIView):
def get(self, request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id)
return Response(serialize_artifact(artifact, request))
def patch(self, request, artifact_id):
if not has_valid_token(request):
return HttpResponseForbidden("Invalid or missing API token")
artifact = get_object_or_404(Artifact, pk=artifact_id)
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"]
artifact.save(
update_fields=[
"title",
"tags",
"source_prompt",
"source_type",
"source_name",
"source_id",
"metadata",
]
)
return Response(serialize_artifact(artifact, request))
class CapabilitiesView(APIView):
def get(self, request):
return Response(
{
"artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES],
"filters": ["persona", "day", "tag", "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",
}
)
def feed(request): def feed(request):
persona = request.GET.get("persona", "") persona = request.GET.get("persona", "")
selected_kind = request.GET.get("kind", "") selected_kind = request.GET.get("kind", "")
title_query = request.GET.get("q", "") title_query = request.GET.get("q", "")
selected_day = parse_date(request.GET.get("day", "")) or timezone.localdate() today = timezone.localdate()
selected_day = parse_date(request.GET.get("day", "")) or today
selected_artifact_id = request.GET.get("artifact", "") 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" selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
all_artifacts = Artifact.objects.all() all_artifacts = Artifact.objects.all()
artifacts = all_artifacts artifacts = all_artifacts
@ -83,12 +153,13 @@ def feed(request):
artifacts = artifacts.filter(title__icontains=title_query) artifacts = artifacts.filter(title__icontains=title_query)
days = list(artifacts.dates("day", "day", order="DESC")) days = list(artifacts.dates("day", "day", order="DESC"))
if selected_day not in days: for day in (today, selected_day):
days.append(selected_day) if day not in days:
days.append(day)
days.sort(reverse=True) days.sort(reverse=True)
day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at") day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at")
selected_artifact = day_artifacts.filter(pk=selected_artifact_id).first() selected_artifact = None if preview_none else day_artifacts.filter(pk=selected_artifact_id).first()
toggle_query = {"day": selected_day.isoformat(), "view": "rendered"} toggle_query = {"day": selected_day.isoformat(), "view": "rendered"}
if persona: if persona:
toggle_query["persona"] = persona toggle_query["persona"] = persona
@ -116,7 +187,8 @@ def feed(request):
"selected_kind": selected_kind, "selected_kind": selected_kind,
"selected_persona": persona, "selected_persona": persona,
"selected_view": selected_view, "selected_view": selected_view,
"today": timezone.localdate(), "preview_none": preview_none,
"today": today,
"title_query": title_query, "title_query": title_query,
"kind_choices": Artifact.KIND_CHOICES, "kind_choices": Artifact.KIND_CHOICES,
}, },
@ -177,6 +249,7 @@ def create_text_artifact(data, request):
original_kind=kind, original_kind=kind,
tags=normalize_tags(data.get("tags", [])), tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""), source_prompt=data.get("source_prompt", ""),
**source_fields(data, persona),
) )
@ -188,6 +261,9 @@ def create_binary_artifact(data):
missing.append("file") missing.append("file")
if missing: if missing:
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400) 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() now = timezone.localtime()
artifact_id = generate_id() artifact_id = generate_id()
@ -217,6 +293,7 @@ def create_binary_artifact(data):
original_kind=kind, original_kind=kind,
tags=normalize_tags(data.get("tags", [])), tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""), source_prompt=data.get("source_prompt", ""),
**source_fields(data, persona),
) )
@ -275,6 +352,41 @@ def normalize_tags(tags):
return [] return []
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)
return None
def source_fields(data, persona):
return {
"source_type": data.get("source_type") or Artifact.AGENT,
"source_name": data.get("source_name") or persona,
"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): def binary_kind(content_type):
if content_type.startswith("image/"): if content_type.startswith("image/"):
return Artifact.IMAGE return Artifact.IMAGE
@ -294,6 +406,9 @@ def serialize_artifact(artifact, request):
return { return {
"id": artifact.id, "id": artifact.id,
"url": request.build_absolute_uri(reverse("artifact", args=[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,
"persona": artifact.persona, "persona": artifact.persona,
"title": artifact.title, "title": artifact.title,
"kind": artifact.kind, "kind": artifact.kind,
@ -302,6 +417,10 @@ def serialize_artifact(artifact, request):
"created_at": artifact.created_at.isoformat(), "created_at": artifact.created_at.isoformat(),
"tags": artifact.tags, "tags": artifact.tags,
"source_prompt": artifact.source_prompt, "source_prompt": artifact.source_prompt,
"source_type": artifact.source_type,
"source_name": artifact.source_name,
"source_id": artifact.source_id,
"metadata": artifact.metadata,
} }

View file

@ -1,11 +1,13 @@
import os
from pathlib import Path from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve()
SECRET_KEY = "dev-only-change-me" SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me")
DEBUG = True DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"}
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()]
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
@ -20,6 +22,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@ -50,7 +53,7 @@ WSGI_APPLICATION = "daybook_project.wsgi.application"
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3", "NAME": os.environ.get("DATABASE_PATH", DATA_DIR / "db.sqlite3"),
} }
} }
@ -63,8 +66,14 @@ USE_TZ = True
STATIC_URL = "static/" STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"] STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
ARTIFACTS_ROOT = BASE_DIR / "artifacts_store" ARTIFACTS_ROOT = Path(os.environ.get("ARTIFACTS_ROOT", DATA_DIR / "artifacts_store"))
DAYBOOK_API_TOKEN = "dev-token" DAYBOOK_API_TOKEN = os.environ.get("DAYBOOK_API_TOKEN", "dev-token")

24
deploy/Dockerfile Normal file
View file

@ -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"]

19
deploy/docker-compose.yml Normal file
View file

@ -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

9
deploy/entrypoint.sh Normal file
View file

@ -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 "$@"

7
deploy/env.example Normal file
View file

@ -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

View file

@ -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 <REPO_URL> /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=<generated-secret>
DJANGO_ALLOWED_HOSTS=<container-ip>,<dns-name-if-any>,localhost,127.0.0.1
DAYBOOK_API_TOKEN=<generated-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://<guest-ip>: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 `<TOKEN>` with `DAYBOOK_API_TOKEN` from `deploy/.env`.
```bash
curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \
-H "Authorization: Bearer <TOKEN>" \
-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://<guest-ip>: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://<guest-ip>: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://<guest-ip>: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.

126
docs/agent_quickstart.md Normal file
View file

@ -0,0 +1,126 @@
# Agent Quickstart
This is the shortest path for an agent to publish and retrieve artifacts from Daybook.
## 1. Check Capabilities
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities"
```
## 2. Publish Markdown
```powershell
$body = @{
persona = "hermes"
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 dev-token" } `
-ContentType "application/json" `
-Body $body
$artifact
```
Open the artifact in the browser:
```powershell
$artifact.url
```
## 3. Publish HTML Fragment
```powershell
$body = @{
persona = "athena"
source_type = "agent"
source_name = "Athena Agent"
kind = "html_fragment"
theme = "tasks"
title = "Task List"
content = "<h1>Tasks</h1><ul><li>Review API docs</li><li>Publish next artifact</li></ul>"
tags = @("tasks")
} | ConvertTo-Json -Depth 5
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer dev-token" } `
-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 dev-token" `
-F "persona=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
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?persona=hermes"
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 dev-token" } `
-ContentType "application/json" `
-Body $body
```
## Agent Payload Checklist
Recommended fields for every agent post:
- `persona`: stable feed identity.
- `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.

332
docs/api.md Normal file
View file

@ -0,0 +1,332 @@
# 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:
```http
Authorization: Bearer dev-token
```
Read endpoints are currently unauthenticated for the local/self-hosted MVP.
## 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.
| Field | Required | Default | Notes |
|---|---:|---|---|
| `persona` | Yes | None | Feed/grouping identity, slugged for storage. |
| `source_type` | No | `agent` | One of `agent`, `person`, `system`. |
| `source_name` | No | `persona` | 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
{
"persona": "hermes",
"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 dev-token
```
Required fields:
| Field | Notes |
|---|---|
| `persona` | 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. |
| `source_prompt` | Prompt or instruction that led to the artifact. |
| `source_type` | Defaults to `agent`. |
| `source_name` | Defaults to `persona`. |
| `source_id` | Optional stable source ID. |
| `metadata` | Optional object. |
PowerShell example:
```powershell
$body = @{
persona = "hermes"
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 dev-token" } `
-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,
"persona": "hermes",
"title": "Morning Brief",
"kind": "markdown",
"theme": "report",
"day": "2026-06-10",
"created_at": "2026-06-10T15:00:00+00:00",
"tags": ["brief", "daily"],
"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"
}
}
```
## Create File Artifact
Endpoint:
```http
POST /api/artifacts
Content-Type: multipart/form-data
Authorization: Bearer dev-token
```
Required fields:
| Field | Notes |
|---|---|
| `persona` | 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 dev-token" `
-F "persona=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"
```
## List Artifacts
Endpoint:
```http
GET /api/artifacts
```
Supported filters:
| Query param | Meaning |
|---|---|
| `persona` | Exact persona match. |
| `day` | Exact day, `YYYY-MM-DD`. |
| `tag` | Matches artifacts containing the tag. |
| `kind` | Exact artifact kind. |
| `q` | Case-insensitive title fragment. |
Examples:
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?persona=hermes"
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&tag=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 dev-token
```
Editable fields:
| Field | Notes |
|---|---|
| `title` | Must not be blank. |
| `tags` | Array or comma-separated string. |
| `source_prompt` | String. |
| `source_type` | `agent`, `person`, or `system`. |
| `source_name` | String. |
| `source_id` | String. |
| `metadata` | Object. Replaces existing metadata. |
Not editable yet:
| Field | Reason |
|---|---|
| `content` | Content replacement/revisions are intentionally deferred. |
| `persona` | 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 dev-token" } `
-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": ["persona", "day", "tag", "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.
## Current MVP Constraints
- A single shared ingest token is used: `dev-token`.
- Reader/browser authentication is not implemented.
- 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.

108
docs/llm_posting_helper.md Normal file
View file

@ -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:8000
DAYBOOK_TOKEN=dev-token
DAYBOOK_PERSONA=agent
```
Override these per environment. For deployed instances, use the token from `deploy/.env`.
## 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 `<section>...</section>` 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 `<section`. |
| `html_fragment` | Must not contain `<!doctype`, `<html`, or `<body`. |
| `html_full` | Must contain `<!doctype` and `<html`. |
| `markdown` | Must not be blank. |
| `.svg` upload | Forces `image/svg+xml`. |
| `.pdf` upload | Forces `application/pdf`. |
## Metadata
Use repeatable `--meta key=value` flags:
```bash
python skills/daybook-posting/scripts/daybook_post.py \
--kind markdown \
--title "Run Summary" \
--content-file summary.md \
--meta run_id=run-001 \
--meta model=gpt-5.5
```
Or pass a JSON object:
```bash
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Run Summary" --content-file summary.md --meta-json '{"run_id":"run-001"}'
```
## Skill Wrapper
A thin LLM-facing wrapper lives at:
```text
skills/daybook-posting/SKILL.md
```
Its key instruction is: do not hand-build requests; use `scripts/daybook_post.py`.

View file

@ -0,0 +1,163 @@
# Daybook — Issues Found During LLM-Posting Testing
**Date:** 2026-06-10
**Context:** Found while smoke-testing the deployed instance (`192.168.1.36:8000`)
and hardening the `daybook-posting` skill's `daybook_post.py` helper. Auth/
reachability items are tracked separately in
`planning/next_steps/llm-posting-skill-plan.md` and not duplicated here.
Severity key: **High** = blocks a stated feature · **Med** = degrades UX or
correctness · **Low** = cosmetic. Status: **Open** / **Fixed** / **By design**.
---
## Open — Server-Side
### S1 · Multipart `metadata` field is not parsed as a JSON object — **High / Open**
- **Symptom:** Any file upload (multipart) that includes a `metadata` field returns
`HTTP 400 {"detail":"metadata must be an object"}`.
- **Repro:** `python daybook_post.py test.svg --meta run_id=1` (or any `--file` +
`--meta`). Text artifacts (JSON path) are unaffected — they send a real object.
- **Root cause:** The multipart handler reads `metadata` as a raw form string and
type-checks it against `dict` without `json.loads`. A stringified `{}` or
`{"k":"v"}` therefore fails the check.
- **Impact:** Machine-readable metadata (run_id, model, etc.) cannot be attached to
image/pdf/binary artifacts — only to text artifacts.
- **Fix:** In the multipart code path, `json.loads` the `metadata` form field before
validating, mirroring the JSON path. Confirm `tags` is handled consistently
(currently sent as a comma string and split server-side).
- **Client workaround in place:** the helper now omits `metadata` from multipart
when empty, so metadata-less uploads work. Non-empty metadata on uploads still
needs this server fix.
### S2 · Users cannot delete or archive artifacts (artifacts are immutable) — **High / Open**
- **Requirement:** Users need to be able to **archive** and **delete** artifacts —
both as API endpoints and as controls in the web UI.
- **Symptom:** There is no delete endpoint, and `content`/`kind`/`persona`/`day`
are not editable. The malformed test deck `5df27410` and the session's test
artifacts can only be superseded, never removed or hidden.
- **Impact:** Test and mistake artifacts accumulate permanently in the feed with no
way for a user to clean it up.
- **Fix — two distinct actions:**
- **Archive:** user-initiated, hides the artifact from the default feed but
retains it and allows restore. Needs an archive/unarchive endpoint, a default
feed filter that excludes archived items, and a UI control (plus an
"archived" view).
- **Delete:** user-initiated permanent removal (or a soft-delete tombstone).
Needs a `DELETE` endpoint, a UI control with a confirm step, and removal of the
stored content/raw file.
- **Related:** consider allowing content replacement for an existing id (revision),
which would also address re-posting a corrected artifact.
### S3 · No renderer for CSV / JSON / code — **Med / Open**
- **Symptom:** These upload as download-only `binary` (e.g. `test.csv` →
`c562128b`, kind `binary`).
- **Impact:** Tabular/code artifacts aren't viewable inline.
- **Fix:** Add a renderer, or formally bless the client's `--as-markdown` fenced-
block convention as the interim path and document it.
### S4 · Favorites and project/group labels — **Med / Open**
- **Requirement:** Users want to (a) mark artifacts as **favorites**, and (b) label
artifacts as belonging to a **group / project**. The group/project label must be
**settable at upload time** (and editable afterward). Favorite is primarily a UI
toggle but should also be settable at upload.
- **Design notes:**
- **Keep this distinct from `tags`.** Tags are many free-form filter labels; a
project/group is a first-class, usually single container used for navigation and
grouping. Recommend a dedicated `project` (or `group`) field rather than
overloading tags. `favorite` is a boolean, not a tag.
- **Data model:** add `favorite: bool = false` and `project: str | null` to the
artifact record.
- **Scope — server / client / UI:**
- **Server:** accept `favorite` and `project` on POST; add endpoints to toggle
favorite and set/clear project after upload; support `?project=` and
`?favorite=true` feed filters.
- **Client (`daybook_post.py`):** add `--project NAME` and `--favorite` flags,
sent as first-class JSON/form fields — NOT inside `metadata` (see S1, which
breaks for multipart string fields).
- **UI:** a favorite (star) toggle and a favorites view; group/filter the feed by
project.
---
## Open — Web UI
### U1 · Feed does not refresh when new artifacts arrive — **Med / Open**
- **Symptom:** The web UI renders a static snapshot of the feed. Artifacts posted
after the page loads do not appear until the user manually reloads.
- **Impact:** Agents publish continuously, but a human watching the feed silently
misses new arrivals — which undercuts Daybook's purpose as a live journal of
agent output. Observed throughout this session: each test post required a manual
browser refresh to show up.
- **Fix options (simplest first):**
- Client-side polling of the list endpoint on an interval (e.g. every few
seconds), diffing against the loaded set.
- Server-sent events (SSE) for push updates — a natural fit for this
publish-only, append-mostly model and lighter than WebSockets.
- To avoid disrupting scroll position, surface a "N new artifacts — click to
load" indicator rather than auto-injecting items at the top.
---
## By Design — Worth a Decision
### D1 · Strict rendering, no normalization — **By design**
- **Behavior:** "Daybook does not fix malformed content." Wrong-shaped content is
stored and rendered as-is.
- **Observed consequence:** Markdown sent to `deck` (`5df27410`) was stored and
rendered malformed — no error, just broken output.
- **Implication:** A validating client is load-bearing. The server's per-kind
structure rules and the client's pre-flight checks must stay in sync; if the
server adds a kind, the client's validation/sniffing must follow.
- **Optional improvement:** Consider rejecting obviously-malformed content with a
`422` (e.g. `deck` with no `<section>`) so it fails loud at post time rather than
rendering broken later. This complements, not replaces, the client guard.
---
## Fixed — Client (recorded for traceability)
### C1 · Empty `metadata` broke all multipart uploads — **Fixed**
- The helper serialized empty `metadata` as the string `"{}"` into the multipart
body, triggering S1's 400 even when no metadata was supplied.
- **Fix:** omit empty optional fields (`metadata`, `tags`, `source_id`,
`source_prompt`) from the multipart body, restoring the minimal known-good shape.
### C2 · `html_full` client validation was over-strict — **Fixed**
- Originally required BOTH `<!doctype` AND `<html>`; an auto-sniffed full page
lacking an explicit doctype was rejected.
- **Fix:** require `<html>` only (doctype recommended, not mandatory).
---
## Minor / Cosmetic
### M1 · `deck` title inference picks the first slide heading — **Low / Open**
- Auto mode derived the title "Slide One" from a deck's first `<h1>` (`ac6da43f`).
Correct but awkward as a title.
- **Fix:** for `deck`, prefer the filename over the first `<h1>`.
### M2 · CSV content-type guessed as `application/vnd.ms-excel` on Windows — **Low / Open**
- Python's `mimetypes` maps `.csv` to `application/vnd.ms-excel` on this host rather
than `text/csv`. Harmless — the server still stores it as `binary` — but
inconsistent with the earlier manual `text/csv` upload.
- **Fix (optional):** force `text/csv` for `.csv` in the client's `content_type_for`.
---
## Test Artifacts Created (for cleanup once S2 lands)
Auto-mode validation posts under persona `claude`, tag `auto-test`:
`e7a769c5` (markdown), `2d162697` (html_full), `49291e85` (html_fragment),
`ac6da43f` (deck), `3ad914cc` (image), `794d29e8` (pdf), `c562128b` (binary).
Plus earlier manual smoke-test artifacts and the malformed deck `5df27410`.

View file

@ -0,0 +1,275 @@
# Framework-Aware Artifact Rendering Ideas
Daybook currently supports a useful split between server-owned rendering and agent-owned rendering:
- `markdown`: agent sends Markdown; Daybook renders and wraps it.
- `html_fragment`: agent sends body-only HTML; Daybook owns the shell and theme CSS.
- `html_full`: agent sends the complete HTML document and owns all presentation.
- `deck`: agent sends slide sections; Daybook wraps them as a simple deck.
The next design question is how to let agents use established CSS/component frameworks without making every artifact depend on arbitrary external assets or custom page shells.
## Core Principle
Agents should publish content, not storage paths or app internals.
For framework-aware artifacts, the preferred model is:
```text
Agent sends framework-compatible body markup
Daybook selects/owns the framework CSS and page shell
Browser renders a consistent artifact preview
```
This preserves the Daybook principle while giving LLMs a known design vocabulary.
## Current Option: `html_full`
Agents can already use any framework by posting `html_full` and including the framework assets themselves.
Example with Bootstrap CDN:
```json
{
"persona": "athena",
"kind": "html_full",
"title": "Bootstrap Report",
"content": "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Bootstrap Report</title><link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css\" rel=\"stylesheet\"></head><body><main class=\"container py-4\"><div class=\"alert alert-success\">Bootstrap artifact rendered by Daybook.</div></main></body></html>"
}
```
Pros:
- Works today.
- Gives the agent total layout control.
- Supports any CSS/JS framework.
Cons:
- CDN dependencies may fail offline.
- External assets can change or disappear.
- Agents must generate a correct full HTML document.
- Visual consistency across artifacts is weaker.
- Security/trust boundaries are looser if arbitrary scripts are included.
## Preferred Future Option: Framework Themes For `html_fragment`
Add Daybook-owned themes that include vendored framework CSS.
Example payload:
```json
{
"persona": "athena",
"kind": "html_fragment",
"theme": "bootstrap",
"title": "Run Summary",
"content": "<main class=\"container py-4\"><div class=\"alert alert-success\">Run complete</div></main>"
}
```
In this model:
- Agent sends body-only framework-compatible markup.
- Daybook wraps it in the HTML shell.
- Daybook links local framework CSS from `static/vendor/...` or `static/themes/...`.
- Daybook remains responsible for stable rendering.
Pros:
- Keeps artifacts self-hosted and predictable.
- Lets agents use known class vocabularies.
- Avoids requiring every agent to write `<head>` boilerplate.
- Preserves the current `html_fragment` contract.
Cons:
- Daybook must vendor and maintain selected frameworks.
- Agents must know which theme/framework is selected.
- Some frameworks expect JavaScript or build tooling.
## Candidate Frameworks
| Framework | Best for | Fit for Daybook |
|---|---|---|
| Bootstrap | Reports, cards, grids, alerts, forms | Strong first candidate. Familiar to LLMs and humans. |
| Pico CSS | Clean semantic documents | Excellent for low-class, readable reports. |
| Bulma | CSS-only layouts/components | Good middle ground; no JS required. |
| Tufte CSS | Research notes, essays, analytical reports | Good specialized report style. |
| Water.css | Simple semantic HTML styling | Good for zero-effort readable HTML. |
| MVP.css | Semantic HTML documents | Good for basic generated reports. |
| Sakura.css | Minimal text-heavy documents | Good for simple readable pages. |
| Tailwind CSS | Highly custom layouts | Powerful but less ideal without a build step. CDN possible for experiments. |
| DaisyUI | Tailwind components | Useful but depends on Tailwind. Better later. |
| Flowbite | Tailwind components | Useful but heavier and Tailwind-dependent. |
| UIKit | Polished dashboards/docs | Possible but less common. |
| Shoelace | Web components | Rich components, but requires JS custom elements. Better later. |
| Reveal.js | Real slide decks | Strong future replacement/upgrade for current `deck`. |
| Mermaid | Diagrams | Not CSS, but important for diagram artifacts. |
| Prism.js / Highlight.js | Code artifacts | Needed for future code renderer. |
| Tabulator / DataTables | CSV/table artifacts | Needed for future table renderer. |
| JSON viewer libraries | JSON artifacts | Needed for future structured JSON renderer. |
## Recommended Initial Set
Start small with CSS-only or mostly CSS-only frameworks:
1. `bootstrap`
2. `pico`
3. `bulma`
4. `tufte`
Reasons:
- No build step required.
- Easy for LLMs to emit usable classes/semantic HTML.
- Useful for the most common artifact types: reports, cards, summaries, dashboards, and notes.
- Can be vendored locally and served through Django static files.
Defer heavier or JS-dependent frameworks until there is a concrete artifact need.
## Theme Naming Idea
Existing themes:
- `report`
- `tasks`
- `deck`
- `base`
Potential additions:
- `bootstrap`
- `pico`
- `bulma`
- `tufte`
- `water`
Agents would specify the theme in the existing `theme` field for `html_fragment` and `markdown` where appropriate.
## Storage/Vendoring Idea
Use local static assets rather than CDN links:
```text
static/
vendor/
bootstrap/
bootstrap.min.css
bulma/
bulma.min.css
pico/
pico.min.css
tufte/
tufte.min.css
themes/
bootstrap.css
bulma.css
pico.css
tufte.css
```
Theme files can either be the vendored framework directly or thin wrappers that import vendor CSS and add Daybook-specific print/preview fixes.
Example:
```css
@import url("/static/vendor/bootstrap/bootstrap.min.css");
body.artifact {
background: #f8f9fa;
}
```
## CDN Policy
CDN links are acceptable for quick experiments using `html_full`, but should not be the default for reliable self-hosted deployments.
Recommended policy:
- MVP experimentation: allow `html_full` with CDN links.
- Durable/self-hosted rendering: prefer vendored local CSS themes.
- Deployment docs should warn that CDN-dependent artifacts may not render offline.
## Security Notes
Framework CSS is relatively safe. Framework JavaScript needs more care.
Riskier artifact types:
- `html_full` with arbitrary scripts.
- JS component frameworks.
- Web components loaded from CDNs.
Current MVP assumes trusted local/self-hosted use. If Daybook later supports untrusted artifact authors or sharing, it should revisit sandboxing and CSP policies.
## Relationship To Future Renderers
Framework themes are separate from future dedicated renderers.
Future renderers likely need their own artifact kinds:
- `json`: collapsible JSON viewer, raw source preserved.
- `csv`: searchable/sortable table, raw CSV preserved.
- `code`: syntax-highlighted code, raw source preserved.
- `diagram`: Mermaid/Graphviz/PlantUML source rendered to SVG/HTML, raw source preserved.
- `audio`: browser audio player.
- `video`: browser video player.
These should use established libraries rather than custom viewers:
- JSON viewer library for JSON.
- Tabulator or DataTables for CSV/table artifacts.
- Prism.js or Highlight.js for code.
- Mermaid for Mermaid diagrams.
- Native `<audio controls>` and `<video controls>` for media.
## Recommended Implementation Order
1. Keep `html_full` as the escape hatch for total agent control.
2. Add one vendored CSS framework theme first, probably Bootstrap.
3. Document exact usage for `theme=bootstrap` with `html_fragment`.
4. Add a sample Bootstrap artifact.
5. Add Pico CSS as a low-complexity report/document theme.
6. Add Bulma or Tufte only if real artifacts need them.
7. Later, add dedicated renderers for JSON, CSV, code, and diagrams.
## Example Bootstrap Fragment
```html
<main class="container py-4">
<div class="row g-3">
<section class="col-md-8">
<div class="card">
<div class="card-body">
<h1 class="card-title">Run Summary</h1>
<p class="card-text">The agent completed all checks successfully.</p>
</div>
</div>
</section>
<aside class="col-md-4">
<div class="alert alert-success">Status: green</div>
</aside>
</div>
</main>
```
Payload:
```json
{
"persona": "athena",
"kind": "html_fragment",
"theme": "bootstrap",
"title": "Bootstrap Run Summary",
"content": "<main class=\"container py-4\">...</main>"
}
```
## Open Questions
- Should framework themes be available for `markdown`, or only `html_fragment`?
- Should Daybook allow framework JavaScript, or CSS only?
- Should `html_full` artifacts be sandboxed more strictly in iframes later?
- Should agents be able to query `/api/capabilities` for supported themes?
- Should themes be exposed as a first-class API field in capabilities?

View file

@ -0,0 +1,260 @@
# Daybook — LLM Posting Skill: Design & Next Steps
**Status:** planning / pre-implementation
**Date:** 2026-06-10
**Author:** drafted by Claude (Opus 4.8) after a live smoke test of the deployed instance
## What This Document Is
A consolidated design recommendation for making **publishing to Daybook trivially
easy for LLM agents**, plus notes on missing functionality surfaced during testing.
Goal: an agent should be able to publish a correct artifact out of the box via a
single command, without hand-crafting HTTP, JSON, or multipart requests.
## What Was Validated (Live Smoke Test)
Tested against the prod instance `http://192.168.1.36:8000` via desktop-commander
(the sandbox cannot reach the `192.168.1.x` LAN, so all calls were driven on-host).
All seven artifact kinds were posted and verified under persona `claude`:
| Kind | Path | Result |
|---|---|---|
| markdown | JSON | OK (`2db087a7`) |
| html_full | JSON | OK (`8cd79771`) |
| deck (markdown — WRONG) | JSON | stored but malformed (`5df27410`) |
| image (svg) | multipart | OK (`83ab938c`) |
| pdf | multipart | OK (`8c82b15c`) |
| binary (csv) | multipart | OK, download-only (`2cceceaf`) |
| deck (`<section>` blocks) | JSON | OK (`60d26e5a`) |
| html_fragment | JSON | OK (`fd42816f`) |
Confirmed working: capabilities endpoint, bearer auth, content-type inference
(svg→image, pdf→pdf, csv→binary), persona/tag/kind filtering, read-back by id.
## The Core Problem To Design Around
Every failure during testing was the same class of problem: **an LLM
hand-assembling a request.** Three concrete failure modes, all observed:
1. **Inline content in shell commands breaks repeatedly.** `powershell -Command`
mangled `$`-expansion, nested quotes, and backtick newlines on three separate
attempts. This is not incidental — every model re-derives the shell incantation
and gets it wrong. It is the single biggest source of friction.
2. **Multipart is environment-dependent.** Windows PowerShell 5.1 has no
`Invoke-RestMethod -Form`; it needs `curl.exe` or PowerShell 7+. An agent cannot
reliably know which host it is running on.
3. **Per-kind structure rules are easy to violate.** Markdown was sent to `deck`
and rendered malformed — precisely because request-building is where judgment
leaks in. The docs' stance is "Daybook does not fix malformed content," so the
client must guarantee structure instead.
**Design conclusion:** the skill must NOT teach the model how to build the request.
It must give the model ONE command to run. All fiddliness (auth, JSON-vs-multipart,
content-type, escaping, kind validation) belongs inside a helper, not in the prompt.
## Recommended Approach
A **bundled CLI helper + a thin SKILL.md**.
- The skill folder ships a single **zero-dependency Python script**
(`scripts/daybook_post.py`, stdlib only). Python runs identically on the NUC
(Linux) and on Windows, eliminating the curl / PS5.1 split entirely.
- `SKILL.md` tells the agent **when** to publish and **that the command exists** —
never how to craft HTTP.
- The script is also usable by any non-Claude agent that can run a shell command,
so it is not locked to the Anthropic skill format. SKILL.md is just the
Claude-flavored discovery wrapper around a universal tool.
## Why This Is "Easy Out Of The Box"
1. **Content never touches the command line.** Content comes from a file or stdin,
so escaping / newlines / quotes never arise:
```bash
cat report.md | python daybook_post.py --kind markdown --title "Build Report"
python daybook_post.py --file diagram.svg --title "Architecture Diagram"
```
2. **One entry point, no branching decisions.** The script decides JSON-vs-multipart
and infers content-type from file extension. The model never picks.
3. **Validate-before-send with fix hints**, turning the strict server contract into
a guardrail the agent cannot trip over.
4. **Prints the artifact URL as the last stdout line**, so the agent reliably
captures the result regardless of verbosity.
### Pre-flight validation table
| Kind / input | Check | Behavior on mismatch |
|---|---|---|
| `deck` | content contains `<section` | error: "deck needs top-level `<section>...</section>` blocks, not Markdown" |
| `html_fragment` | no `<html>` / `<!doctype` | error: "fragment must be body-only HTML" |
| `html_full` | has `<!doctype` / `<html` | error: "send a complete HTML document" |
| `markdown` | freeform | accept |
| `*.svg` upload | force `image/svg+xml` | auto-fix (not an error) |
| `*.pdf` upload | force `application/pdf` | auto-fix |
| other upload | extension→mime, else `octet-stream` | store as binary |
## Proposed CLI Surface (Deliberately Small)
```
daybook_post.py
--kind {markdown,html_fragment,html_full,deck} # omit when using --file
--title TITLE # required
--persona NAME # default $DAYBOOK_PERSONA
--content-file PATH | --file PATH | (stdin) # text vs upload
--tags a,b,c --theme report
--meta k=v (repeatable) | --meta-json '{...}'
--source-name ... --source-id ... --source-prompt ...
--check # GET /api/capabilities and exit
--dry-run # print the request it would send, do not post
```
Config via env with prod fallbacks: `DAYBOOK_URL`, `DAYBOOK_TOKEN`,
`DAYBOOK_PERSONA`. Zero-config by default, fully overridable.
## SKILL.md Design
The single most important field for out-of-the-box use is the **description**,
because that is what makes an agent reach for the skill unprompted. Key on *intent*,
not mechanism:
> Use when you have produced a finished deliverable — a report, summary, brief,
> diagram, generated HTML, PDF, or status update — that a human should be able to
> review later outside this chat. Publishes it to Daybook and returns a URL.
The body of SKILL.md should be short:
- One line on what Daybook is (publish-only artifact journal).
- The single command pattern (file/stdin in, URL out).
- A 4-row kind cheat-sheet (markdown / fragment / full / deck → what content shape).
- "Run `--check` first if unsure the instance is up."
- Explicitly: do NOT hand-build curl/JSON; always use the script.
Proposed skill folder layout:
```
agent-daybook/ (skill root)
SKILL.md
scripts/
daybook_post.py # the one helper, stdlib only
.env.example # DAYBOOK_URL / DAYBOOK_TOKEN / DAYBOOK_PERSONA
```
## Open Decisions (Need Your Call)
1. **Token handling.** Baking the prod token as a fallback default = truly
zero-config, but it lands in the skill folder / repo. Cleaner: require
`DAYBOOK_TOKEN` and ship `.env.example`. Recommendation: zero-config now while
it is a local MVP, tighten to env-only before this leaves the LAN.
2. **Helper location.** Bundle inside the skill (`scripts/`) for a self-contained,
no-install drop-in — recommended — vs a system-wide install that reintroduces
PATH friction.
3. **Auto-wrap convenience kinds.** Should the script auto-wrap a `.csv`/`.json`
into a Markdown fenced block so it renders today (server has no CSV/JSON
renderer yet)? Convenient, but slightly magic. Suggest: opt-in flag
`--as-markdown`, not default.
## Server-Side / Missing Functionality Notes
Observed during testing. The thin client absorbs MVP gaps so the server can stay
strict; these are candidates for later server work, not blockers.
- **No DELETE / no content revision.** Once posted, an artifact is permanent;
`content`/`kind`/`persona`/`day` are not PATCH-editable. The malformed test deck
`5df27410` can only be superseded, not removed. Consider a soft-delete or
`archived` flag for cleanup of test/mistake artifacts.
- **No CSV/JSON/code renderer.** These store as download-only `binary`. Fine for
now; the client can wrap them as Markdown on request. A dedicated renderer is
the eventual fix.
- **Single shared token, no reader auth.** Acceptable on a trusted LAN; revisit
before any wider exposure.
- **No pagination/cursors on list.** Fine at current volume; will matter once
feeds grow.
- **Strict-by-design rendering.** "Daybook does not fix malformed content" is a
reasonable stance IF the standard publish path is a validating client. The skill
is what makes that stance safe — keep them in sync (if the server adds a kind,
the script's validation table must follow).
## Suggested Build Order
1. Write `scripts/daybook_post.py` (stdlib: `urllib`, hand-rolled multipart,
extension→mime map, kind validation, stdin/file content, URL on last line).
2. Smoke-test it against the live instance for all four text kinds + the three
file kinds — same coverage as the manual test, but each via one command.
3. Write `SKILL.md` (intent-keyed description + the cheat-sheet) and `.env.example`.
4. Verify discovery: confirm an agent picks the skill from the description alone.
5. Later/server: soft-delete or archive flag; CSV/JSON renderer; token hardening
before the instance leaves the LAN.
## Appendix: Environment Facts (from test host)
- Prod instance reachable only on LAN `192.168.1.36:8000` — agents must be on-network.
- Host is Windows PowerShell **5.1** (no `-Form`); `curl.exe` present via Git;
Python **3.12** present. Python helper avoids all shell-specific pitfalls.
- Inlining content into `powershell -Command` is the confirmed failure mode;
file/stdin input is the fix.
---
## Architecture Decision: Public REST API as the Universal Substrate
**Decided:** 2026-06-10 · **Status:** accepted direction; hardening deferred (see below)
### Core conclusion
The private LAN address (`192.168.1.36`) was the *only* thing forcing MCP and
desktop-commander workarounds. Nothing about Daybook intrinsically requires them.
Host Daybook on a publicly routable VM and the reachability wall disappears for
every agent at once. The generic, universal interface is simply the **public
HTTPS REST API** — any LLM or agent with an HTTP client can `POST /api/artifacts`
in any language. A client script is optional sugar; MCP drops from "required" to
"nice-to-have."
### The layering (everything hangs off the REST API)
| Layer | Role | Required? |
|---|---|---|
| Public HTTPS REST API | The universal substrate. This is the product. | Yes — core |
| Thin client (`daybook_post.py`, path-based) | One-liner ergonomics over REST | Optional |
| MCP server (local stdio) | Typed calls + auto-discovery for MCP-native agents | Optional polish |
| Skill (`SKILL.md`) | Claude-specific pointer to the script/REST | Optional |
Build bottom-up: REST alone is enough to be generic. Add the others only where a
specific client benefits.
### Reachability by environment (once Daybook is public)
| Environment | Can it POST directly? | Notes |
|---|---|---|
| Claude Code / laptop / CI / arbitrary agent | Yes | Open internet + HTTP client. The 95% case. |
| claude.ai / Desktop chat sandbox (`bash_tool`) | Yes, after one toggle | Add the Daybook domain to the bash **egress allowlist** in network settings; then a sandbox python script reads a sandbox-generated file and POSTs. No desktop-commander, no MCP. |
| claude.ai `web_fetch` | No | GET-oriented public fetcher; cannot do an authenticated POST with a body. Use `bash_tool` instead. |
Key point: in the all-public case the file-location problem also dissolves — the
generated file and the posting script are co-located in the same sandbox, and the
target is publicly routable.
### Deferred: auth hardening for the LAN -> public transition
The reachability story gets *simpler* when public; the auth story gets *stricter*.
The single shared dev token is fine on a trusted LAN and unacceptable on the open
internet. Before any real public exposure:
- [ ] Per-agent API keys (revocable, attributable), not one shared token
- [ ] TLS / HTTPS only
- [ ] Rate limiting + upload size/abuse guards
- [ ] Write-scoping per persona (an agent can only post as its own persona)
- [ ] Basic audit log of who posted what
### NOT needed for current testing (explicit)
For testing the basic idea and playing with dummy documents, **none of the above
is required**. The current setup is sufficient:
- Keep running on the LAN (`192.168.1.36:8000`) with the single dev token.
- Post dummy artifacts via desktop-commander (this chat) or the CLI (Claude Code).
- Treat the auth-hardening checklist as a gate that applies *only* when Daybook
moves to a public host for real use — not before.
Sequencing: prove the posting ergonomics and rendering with dummy docs first;
take on public deployment + auth hardening only once the basic idea is validated.

View file

@ -1,3 +1,5 @@
Django>=5.0,<6.0 Django>=5.0,<6.0
djangorestframework>=3.15,<4.0 djangorestframework>=3.15,<4.0
Markdown>=3.6,<4.0 Markdown>=3.6,<4.0
gunicorn>=23.0,<24.0
whitenoise>=6.7,<7.0

26
scripts/README.md Normal file
View file

@ -0,0 +1,26 @@
# 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:8000
DAYBOOK_TOKEN=dev-token
DAYBOOK_PERSONA=agent
```
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.

16
scripts/daybook_post.py Normal file
View file

@ -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__")

View file

@ -0,0 +1,54 @@
---
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:8000
DAYBOOK_TOKEN=dev-token
DAYBOOK_PERSONA=agent
```
## 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 `<section>...</section>` blocks. |
For images, PDFs, SVGs, and other files, use `--file`; the helper infers multipart and content type.
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_PERSONA` or pass `--persona`.
- 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.

View file

@ -0,0 +1,378 @@
#!/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:8000"
DEFAULT_TOKEN = "dev-token"
DEFAULT_PERSONA = "agent"
def main():
args = parse_args()
base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/")
token = os.environ.get("DAYBOOK_TOKEN", DEFAULT_TOKEN)
persona = args.persona or os.environ.get("DAYBOOK_PERSONA", DEFAULT_PERSONA)
if args.check:
response = request_json("GET", f"{base_url}/api/capabilities")
print(json.dumps(response, indent=2))
return 0
try:
if args.path:
resolve_auto_path(args)
payload = build_payload(args, persona)
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("--persona", help="Artifact persona. Defaults to DAYBOOK_PERSONA 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 persona.")
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("--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:
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 "<!doctype" in lower or "<html" in lower:
return "html_full"
if "<section" in lower:
return "deck"
return "html_fragment"
def derive_title(path, kind, text):
if kind == "markdown":
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip()
else:
match = re.search(r"<title[^>]*>(.*?)</title>", text, re.IGNORECASE | re.DOTALL)
if match and match.group(1).strip():
return match.group(1).strip()
match = re.search(r"<h1[^>]*>(.*?)</h1>", 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, persona):
metadata = parse_metadata(args)
common = {
"persona": persona,
"title": args.title,
"tags": normalize_tags(args.tags),
"source_type": args.source_type,
"source_name": args.source_name or persona,
"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 "<section" not in content_lower:
raise ValueError("deck needs top-level <section>...</section> blocks, not Markdown or unwrapped headings")
if kind == "html_fragment" and ("<html" in content_lower or "<!doctype" in content_lower or "<body" in content_lower):
raise ValueError("html_fragment must be body-only HTML; do not include doctype/html/head/body")
if kind == "html_full" and "<html" not in content_lower:
raise ValueError("html_full must be a complete HTML document with <html> (and ideally <!doctype html>)")
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"<content length={len(payload['content'])}>"
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 request_json(method, url):
request = Request(url, method=method)
request.add_header("Accept", "application/json")
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())

View file

@ -5,7 +5,15 @@
color: #1d2528; color: #1d2528;
} }
html,
body { body {
height: 100%;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
margin: 0; margin: 0;
} }
@ -14,23 +22,17 @@ body {
background: #17313b; background: #17313b;
color: #fffaf0; color: #fffaf0;
display: grid; display: grid;
flex: 0 0 auto;
gap: 0; gap: 0;
grid-template-columns: minmax(13rem, 25%) 1fr; grid-template-columns: max-content 1fr;
padding: 0; padding: 0;
} }
.brandline { .brandline {
align-items: baseline; align-items: center;
display: flex; display: flex;
gap: 0.55rem; gap: 0.55rem;
padding: 0.38rem 0.65rem; padding: 0.38rem 0.8rem;
}
.brandline p {
color: #d9e5e8;
font-size: 0.78rem;
font-weight: 600;
margin: 0;
} }
.eyebrow { .eyebrow {
@ -119,8 +121,10 @@ h1 {
--artifacts-width: 280px; --artifacts-width: 280px;
--days-width: 144px; --days-width: 144px;
display: grid; display: grid;
flex: 1 1 auto;
grid-template-columns: var(--days-width) 1px var(--artifacts-width) 1px minmax(0, 1fr); grid-template-columns: var(--days-width) 1px var(--artifacts-width) 1px minmax(0, 1fr);
min-height: calc(100vh - 3.2rem); min-height: 0;
overflow: hidden;
} }
.day-sidebar, .day-sidebar,
@ -130,6 +134,60 @@ h1 {
padding: 0.55rem; padding: 0.55rem;
} }
.day-sidebar,
.artifact-sidebar {
min-height: 0;
overflow-x: hidden;
scrollbar-color: transparent transparent;
scrollbar-width: none;
}
.day-sidebar {
overflow-y: auto;
}
.artifact-sidebar {
display: flex;
flex-direction: column;
overflow-y: hidden;
padding: 0;
}
.artifact-list {
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-color: transparent transparent;
scrollbar-width: none;
}
.day-sidebar:hover,
.artifact-sidebar:hover .artifact-list {
scrollbar-color: rgba(23, 49, 59, 0.35) transparent;
scrollbar-width: thin;
}
.day-sidebar::-webkit-scrollbar,
.artifact-list::-webkit-scrollbar {
width: 0;
}
.day-sidebar:hover::-webkit-scrollbar,
.artifact-sidebar:hover .artifact-list::-webkit-scrollbar {
width: 6px;
}
.day-sidebar::-webkit-scrollbar-thumb,
.artifact-list::-webkit-scrollbar-thumb {
background: rgba(23, 49, 59, 0.35);
border-radius: 999px;
}
.day-sidebar::-webkit-scrollbar-track,
.artifact-list::-webkit-scrollbar-track {
background: transparent;
}
.day-sidebar { .day-sidebar {
background: #ebe4d5; background: #ebe4d5;
} }
@ -143,6 +201,7 @@ h1 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
min-height: 0;
padding: 0; padding: 0;
} }
@ -185,6 +244,9 @@ h1 {
.day-list, .day-list,
.artifact-list { .artifact-list {
display: grid; display: grid;
}
.day-list {
gap: 0.35rem; gap: 0.35rem;
} }
@ -195,7 +257,7 @@ h1 {
} }
.day-link { .day-link {
border-radius: 10px; border-radius: 6px;
display: grid; display: grid;
gap: 0.1rem; gap: 0.1rem;
padding: 0.48rem; padding: 0.48rem;
@ -207,6 +269,11 @@ h1 {
box-shadow: 0 10px 24px rgba(57, 44, 28, 0.08); box-shadow: 0 10px 24px rgba(57, 44, 28, 0.08);
} }
.day-link:focus-visible {
outline: 2px solid #e2b66a;
outline-offset: 2px;
}
.day-link.selected { .day-link.selected {
background: #17313b; background: #17313b;
color: #fffaf0; color: #fffaf0;
@ -224,7 +291,7 @@ h1 {
.day-link span, .day-link span,
.pane-heading span, .pane-heading span,
.artifact-row-main strong { .artifact-time {
color: #7b6b55; color: #7b6b55;
font-size: 0.7rem; font-size: 0.7rem;
} }
@ -250,84 +317,136 @@ h1 {
align-items: baseline; align-items: baseline;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 0.45rem; margin: 0;
padding: 0.55rem;
}
.new-artifacts-banner {
background: #245238;
border: 0;
border-radius: 999px;
color: #f6fff2;
cursor: pointer;
display: block;
font: inherit;
font-size: 0.76rem;
font-weight: 700;
margin: 0 0.55rem 0.5rem;
padding: 0.48rem 0.7rem;
text-align: center;
width: 100%;
}
.new-artifacts-banner[hidden] {
display: none;
}
.new-artifacts-banner:hover {
background: #17313b;
} }
.artifact-list { .artifact-list {
align-content: start; align-content: start;
flex: 1 1 auto;
} }
.artifact-row { .artifact-row {
align-items: center; align-items: stretch;
background: #fffaf0; background: #fffaf0;
border: 1px solid #e5d8c0; border: 0;
border-radius: 11px; border-radius: 0;
display: flex; display: grid;
gap: 0.45rem; gap: 0;
padding: 0.45rem; grid-template-columns: 3.15rem minmax(0, 1fr) 2.6rem;
min-height: 2.7rem;
overflow: hidden;
padding: 0;
} }
.artifact-row:hover { .artifact-row:hover {
border-color: #c9b38d; background: #fff6e5;
box-shadow: 0 12px 28px rgba(57, 44, 28, 0.08); box-shadow: none;
}
.artifact-row:focus-visible {
outline: 0;
box-shadow: inset 0 0 0 1px #e2b66a;
} }
.artifact-row.selected { .artifact-row.selected {
background: #17313b; background: #17313b;
border-color: #17313b;
color: #fffaf0; color: #fffaf0;
} }
.artifact-row.selected .artifact-row-main strong, .artifact-row.selected .artifact-time {
.artifact-row.selected .artifact-row-main span { background: #10262f;
}
.artifact-row.selected .artifact-row-main span,
.artifact-row.selected .artifact-time {
color: #fffaf0;
}
.artifact-row.selected .kind-icon {
background: rgba(255, 250, 240, 0.12);
color: #fffaf0; color: #fffaf0;
} }
.kind-icon { .kind-icon {
align-items: center; align-items: center;
background: #dfe9ea; background: rgba(23, 49, 59, 0.04);
border-radius: 7px; color: #657173;
color: #17313b;
display: inline-flex; display: inline-flex;
flex: 0 0 2.25rem; font-size: 0.56rem;
font-size: 0.58rem;
font-weight: 800; font-weight: 800;
height: 1.75rem;
justify-content: center; justify-content: center;
letter-spacing: 0.04em; letter-spacing: 0.04em;
line-height: 1;
padding: 0 0.42rem;
white-space: nowrap;
} }
.kind-image { .kind-image {
background: #dfeee2;
color: #265336; color: #265336;
} }
.kind-pdf { .kind-pdf {
background: #f3dedb;
color: #78352f; color: #78352f;
} }
.kind-markdown { .kind-markdown {
background: #e7e1f2;
color: #443265; color: #443265;
} }
.kind-deck { .kind-deck {
background: #f6e5bf;
color: #6e4a12; color: #6e4a12;
} }
.artifact-row-main { .artifact-row-main {
display: grid; align-items: center;
gap: 0.1rem; display: flex;
min-width: 0; min-width: 0;
padding: 0.42rem 0.55rem;
} }
.artifact-row-main span { .artifact-row-main span {
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: #17313b; color: #17313b;
display: -webkit-box;
font-size: 0.86rem; font-size: 0.86rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; }
.artifact-time {
align-items: center;
background: #efe5d3;
display: flex;
justify-content: flex-end;
justify-self: stretch;
line-height: 1;
padding: 0 0.48rem;
text-align: right;
white-space: nowrap; white-space: nowrap;
} }
@ -350,6 +469,20 @@ h1 {
max-width: 34rem; max-width: 34rem;
} }
kbd {
background: #ebe4d5;
border: 1px solid #d3c3a7;
border-radius: 4px;
color: #17313b;
font: 0.85em ui-monospace, SFMono-Regular, Consolas, monospace;
padding: 0.08rem 0.28rem;
}
.preview-pane > .preview-empty {
align-self: center;
margin: auto;
}
.preview-frame-wrap { .preview-frame-wrap {
align-items: center; align-items: center;
display: flex; display: flex;
@ -410,6 +543,12 @@ h1 {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
html,
body {
height: auto;
overflow: auto;
}
.topbar { .topbar {
align-items: stretch; align-items: stretch;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -424,6 +563,7 @@ h1 {
.feed { .feed {
grid-template-columns: 1fr; grid-template-columns: 1fr;
overflow: visible;
} }
.splitter { .splitter {
@ -434,5 +574,6 @@ h1 {
.artifact-sidebar { .artifact-sidebar {
border-bottom: 1px solid #d9c9ad; border-bottom: 1px solid #d9c9ad;
border-right: 0; border-right: 0;
max-height: 32vh;
} }
} }

View file

@ -3,14 +3,13 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Daybook</title> <title>AgentFeed</title>
<link rel="stylesheet" href="/static/daybook.css"> <link rel="stylesheet" href="/static/daybook.css">
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brandline"> <div class="brandline">
<h1>Daybook</h1> <h1>AgentFeed</h1>
<p>Agent Artifacts</p>
</div> </div>
<div class="toolbar"> <div class="toolbar">
<form method="get" class="filter"> <form method="get" class="filter">
@ -44,12 +43,16 @@
</div> </div>
</header> </header>
<main class="feed"> <main class="feed"
data-selected-day="{{ selected_day|date:'Y-m-d' }}"
data-selected-persona="{{ selected_persona }}"
data-selected-kind="{{ selected_kind }}"
data-title-query="{{ title_query }}">
<aside class="day-sidebar" aria-label="Days"> <aside class="day-sidebar" aria-label="Days">
<h2>Days</h2> <h2>Days</h2>
<nav class="day-list"> <nav class="day-list">
{% for day in days %} {% for day in days %}
<a class="day-link {% if day == selected_day %}selected{% endif %}" href="?day={{ day|date:'Y-m-d' }}{% if selected_persona %}&amp;persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}"> <a class="day-link {% if day == selected_day %}selected{% endif %}" href="?day={{ day|date:'Y-m-d' }}{% if selected_persona %}&amp;persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}&amp;preview=none">
<span>{{ day|date:"D" }}{% if day == today %}<em class="today-badge">Today</em>{% endif %}</span> <span>{{ day|date:"D" }}{% if day == today %}<em class="today-badge">Today</em>{% endif %}</span>
<strong>{{ day|date:"Y-m-d" }}</strong> <strong>{{ day|date:"Y-m-d" }}</strong>
</a> </a>
@ -65,6 +68,10 @@
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span> <span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
</div> </div>
<button class="new-artifacts-banner" type="button" hidden data-new-artifacts-banner>
<strong data-new-artifacts-count>0</strong> new artifact<span data-new-artifacts-plural>s</span> available
</button>
<div class="artifact-list"> <div class="artifact-list">
{% for artifact in artifacts %} {% for artifact in artifacts %}
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %}" <a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %}"
@ -74,18 +81,16 @@
data-kind="{{ artifact.kind }}" data-kind="{{ artifact.kind }}"
data-rendered-url="{% url 'artifact' artifact.id %}" data-rendered-url="{% url 'artifact' artifact.id %}"
data-raw-url="{% url 'artifact' artifact.id %}?raw=1"> data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
<span class="kind-icon kind-{{ artifact.kind }}" aria-hidden="true"> <time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
{% if artifact.kind == "image" %}IMG{% elif artifact.kind == "pdf" %}PDF{% elif artifact.kind == "markdown" %}MD{% elif artifact.kind == "deck" %}DECK{% elif artifact.kind == "binary" %}BIN{% else %}HTML{% endif %}
</span>
<span class="artifact-row-main"> <span class="artifact-row-main">
<strong>{{ artifact.created_at|date:"H:i" }}</strong>
<span>{{ artifact.title }}</span> <span>{{ artifact.title }}</span>
</span> </span>
<span class="kind-icon kind-{{ artifact.kind }}" aria-hidden="true">{% if artifact.kind == "image" %}IMG{% elif artifact.kind == "pdf" %}PDF{% elif artifact.kind == "markdown" %}MD{% elif artifact.kind == "deck" %}DECK{% elif artifact.kind == "binary" %}BIN{% else %}HTML{% endif %}</span>
</a> </a>
{% empty %} {% empty %}
<div class="empty compact"> <div class="empty compact">
<h3>No artifacts</h3> <h3>No artifacts</h3>
<p>This day is ready for new agent output.</p> <p>No artifacts exist for this day yet.</p>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -124,9 +129,13 @@
</footer> </footer>
{% else %} {% else %}
<div class="empty preview-empty"> <div class="empty preview-empty">
<h2>Select an artifact</h2> {% if artifacts %}
<p>Choose an item from the middle column to preview it here.</p> <h2>No artifact loaded</h2>
<p>POST to <code>/api/artifacts</code> with <code>Authorization: Bearer dev-token</code>.</p> <p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
{% else %}
<h2>No artifacts for this day</h2>
<p>This day is ready for new agent output.</p>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</section> </section>
@ -179,6 +188,11 @@
const previewPane = document.querySelector(".preview-pane"); const previewPane = document.querySelector(".preview-pane");
const rows = Array.from(document.querySelectorAll(".artifact-row")); const rows = Array.from(document.querySelectorAll(".artifact-row"));
const dayLinks = Array.from(document.querySelectorAll(".day-link"));
const initialArtifactIds = new Set(rows.map((row) => row.dataset.artifactId));
const newArtifactsBanner = document.querySelector("[data-new-artifacts-banner]");
const newArtifactsCount = document.querySelector("[data-new-artifacts-count]");
const newArtifactsPlural = document.querySelector("[data-new-artifacts-plural]");
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({ const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
"&": "&amp;", "&": "&amp;",
@ -200,11 +214,21 @@
}, 1200); }, 1200);
}; };
const selectedRow = () => document.querySelector(".artifact-row.selected"); const selectedRow = () => document.querySelector(".artifact-row.selected");
let navigationMode = selectedRow() ? "artifacts" : "days";
const urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl; const urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl;
const pageUrlFor = (row, view) => { const pageUrlFor = (row, view) => {
const url = new URL(row.href, window.location.origin); const url = new URL(row.href, window.location.origin);
url.searchParams.set("artifact", row.dataset.artifactId); url.searchParams.set("artifact", row.dataset.artifactId);
url.searchParams.set("view", view); url.searchParams.set("view", view);
url.searchParams.delete("preview");
return url;
};
const noPreviewUrl = () => {
const url = new URL(window.location.href);
url.searchParams.delete("artifact");
url.searchParams.set("preview", "none");
url.searchParams.set("view", selectedView);
return url; return url;
}; };
@ -224,6 +248,7 @@
}; };
const renderPreview = (row, pushState = true) => { const renderPreview = (row, pushState = true) => {
navigationMode = "artifacts";
rows.forEach((candidate) => candidate.classList.toggle("selected", candidate === row)); rows.forEach((candidate) => candidate.classList.toggle("selected", candidate === row));
const source = urlFor(row, selectedView); const source = urlFor(row, selectedView);
const title = escapeHtml(row.dataset.title); const title = escapeHtml(row.dataset.title);
@ -246,6 +271,18 @@
if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView)); if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView));
}; };
const clearPreview = () => {
navigationMode = "days";
if (document.activeElement && document.activeElement.classList.contains("artifact-row")) {
document.activeElement.blur();
}
rows.forEach((candidate) => candidate.classList.remove("selected"));
previewPane.innerHTML = `<div class="empty preview-empty"><h2>No artifact loaded</h2><p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p></div>`;
window.history.pushState({}, "", noPreviewUrl());
const currentDay = document.querySelector(".day-link.selected");
if (currentDay) currentDay.focus({preventScroll: true});
};
rows.forEach((row) => { rows.forEach((row) => {
row.addEventListener("click", (event) => { row.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
@ -253,6 +290,119 @@
}); });
}); });
const moveSelection = (direction) => {
if (!rows.length) return;
navigationMode = "artifacts";
const current = selectedRow();
const currentIndex = current ? rows.indexOf(current) : -1;
const nextIndex = Math.max(0, Math.min(rows.length - 1, currentIndex + direction));
const nextRow = rows[nextIndex === -1 ? 0 : nextIndex];
renderPreview(nextRow);
nextRow.focus({preventScroll: true});
nextRow.scrollIntoView({block: "nearest"});
};
const moveDay = (direction) => {
if (!dayLinks.length) return;
navigationMode = "days";
const current = document.querySelector(".day-link.selected");
const currentIndex = current ? dayLinks.indexOf(current) : 0;
const nextIndex = Math.max(0, Math.min(dayLinks.length - 1, currentIndex + direction));
const nextDay = dayLinks[nextIndex];
if (nextDay && nextDay !== current) window.location.href = nextDay.href;
};
document.addEventListener("keydown", (event) => {
if (event.ctrlKey || event.metaKey || event.shiftKey) return;
if (["INPUT", "SELECT", "TEXTAREA", "BUTTON"].includes(event.target.tagName)) return;
if (event.altKey && event.key === "ArrowDown") {
event.preventDefault();
moveDay(1);
} else if (event.altKey && event.key === "ArrowUp") {
event.preventDefault();
moveDay(-1);
} else if (event.altKey) {
return;
} else if (event.key === "ArrowDown") {
event.preventDefault();
if (navigationMode === "days") {
moveDay(1);
} else {
moveSelection(1);
}
} else if (event.key === "ArrowUp") {
event.preventDefault();
if (navigationMode === "days") {
moveDay(-1);
} else {
moveSelection(-1);
}
} else if (event.key === "PageDown") {
if (navigationMode === "artifacts") {
event.preventDefault();
moveSelection(5);
}
} else if (event.key === "PageUp") {
if (navigationMode === "artifacts") {
event.preventDefault();
moveSelection(-5);
}
} else if (event.key === "ArrowRight") {
if (!selectedRow() && rows.length) {
event.preventDefault();
renderPreview(rows[0]);
rows[0].focus({preventScroll: true});
rows[0].scrollIntoView({block: "nearest"});
} else if (selectedRow()) {
navigationMode = "artifacts";
}
} else if (event.key === "ArrowLeft") {
if (selectedRow()) {
event.preventDefault();
clearPreview();
} else {
navigationMode = "days";
const currentDay = document.querySelector(".day-link.selected");
if (currentDay) currentDay.focus({preventScroll: true});
}
}
});
const artifactListUrl = () => {
const url = new URL("/api/artifacts", window.location.origin);
const params = url.searchParams;
if (feed.dataset.selectedDay) params.set("day", feed.dataset.selectedDay);
if (feed.dataset.selectedPersona) params.set("persona", feed.dataset.selectedPersona);
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
return url;
};
const showNewArtifactsBanner = (count) => {
if (!newArtifactsBanner || count < 1) return;
newArtifactsCount.textContent = String(count);
newArtifactsPlural.hidden = count === 1;
newArtifactsBanner.hidden = false;
};
const checkForNewArtifacts = async () => {
try {
const response = await fetch(artifactListUrl(), {headers: {Accept: "application/json"}});
if (!response.ok) return;
const artifacts = await response.json();
const unseenCount = artifacts.filter((artifact) => !initialArtifactIds.has(artifact.id)).length;
showNewArtifactsBanner(unseenCount);
} catch {
// Polling is opportunistic; leave the current feed untouched on errors.
}
};
if (newArtifactsBanner) {
newArtifactsBanner.addEventListener("click", () => window.location.reload());
window.setInterval(checkForNewArtifacts, 5000);
}
previewPane.addEventListener("click", (event) => { previewPane.addEventListener("click", (event) => {
const copyButton = event.target.closest("[data-copy-url]"); const copyButton = event.target.closest("[data-copy-url]");
if (copyButton) { if (copyButton) {