Refine AgentFeed UI and local workflow
This commit is contained in:
parent
3179f832fc
commit
ba9f5eb168
29 changed files with 2986 additions and 69 deletions
19
.dockerignore
Normal file
19
.dockerignore
Normal 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/
|
||||||
19
AGENTS.md
19
AGENTS.md
|
|
@ -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
28
_archive_/hosts/README.md
Normal 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.
|
||||||
85
_archive_/hosts/cloud-template.md
Normal file
85
_archive_/hosts/cloud-template.md
Normal 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 |
|
||||||
145
_archive_/hosts/nuc-daybook.md
Normal file
145
_archive_/hosts/nuc-daybook.md
Normal 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
|
||||||
|
```
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
]
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
24
deploy/Dockerfile
Normal 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
19
deploy/docker-compose.yml
Normal 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
9
deploy/entrypoint.sh
Normal 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
7
deploy/env.example
Normal 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
|
||||||
348
deploy/proxmox_llm_deploy.md
Normal file
348
deploy/proxmox_llm_deploy.md
Normal 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
126
docs/agent_quickstart.md
Normal 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
332
docs/api.md
Normal 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
108
docs/llm_posting_helper.md
Normal 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`.
|
||||||
163
planning/daybook-issues-log.md
Normal file
163
planning/daybook-issues-log.md
Normal 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`.
|
||||||
275
planning/framework_artifact_rendering.md
Normal file
275
planning/framework_artifact_rendering.md
Normal 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?
|
||||||
260
planning/next_steps/llm-posting-skill-plan.md
Normal file
260
planning/next_steps/llm-posting-skill-plan.md
Normal 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.
|
||||||
|
|
@ -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
26
scripts/README.md
Normal 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
16
scripts/daybook_post.py
Normal 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__")
|
||||||
54
skills/daybook-posting/SKILL.md
Normal file
54
skills/daybook-posting/SKILL.md
Normal 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.
|
||||||
378
skills/daybook-posting/scripts/daybook_post.py
Normal file
378
skills/daybook-posting/scripts/daybook_post.py
Normal 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())
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 %}&persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}">
|
<a class="day-link {% if day == selected_day %}selected{% endif %}" href="?day={{ day|date:'Y-m-d' }}{% if selected_persona %}&persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&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) => ({
|
||||||
"&": "&",
|
"&": "&",
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue