feat(t-paliad-146): Paliadin PoC — tmux-Claude in-app AI buddy
Phase 0 of the Paliadin design (docs/design-paliadin-2026-05-07.md
§0.5). m-only laptop scope, gated behind PALIADIN_ENABLED=false on
prod. Lifts the goldi/mVoice tmux-Claude pattern (mVoice/server.py:
250-380) into a Go service: long-lived `claude` pane in a tmux
session, prompts in via `tmux send-keys -l`, responses out via a
per-turn file (/tmp/paliadin/{turn_id}.txt) the system prompt
instructs Claude to write.
What landed
-----------
- migration 058_paliadin_poc — paliad.paliadin_turns audit table
(full prompt + response stored at PoC scope; redaction returns
at production v1 per design §3.3). RLS: user sees own,
global_admin sees all.
- internal/services/paliadin.go — the orchestrator. ensurePane()
finds-or-creates the tagged tmux window, sendToPane sends the
framed [PALIADIN:turn_id] envelope, pollForResponse reads the
per-turn file, splitTrailer parses the [paliadin-meta] block
Claude appends to every reply (used_tools, rows_seen,
classifier_tag).
- internal/services/paliadin_prompt.go — the system prompt sent
once to a fresh Claude pane. Defines the response protocol
(Write-to-file + meta trailer), the action-chip marker syntax,
the visibility-gate rule (paliad.can_see_project required in
every project-scoped query), and 9 SQL recipes covering m's
paliad data + cross-schema youpc case-law lookup.
- internal/handlers/paliadin.go — POST /api/paliadin/turn kicks
off the work in a goroutine and returns an SSE URL; GET
/api/paliadin/stream/{id} relays per-turn channel events
(meta/content/end/error/ping) to EventSource. Routes register
ONLY when PaliadinService is wired — paliadinSvc nil → no
handlers exist, prod surface is clean.
- /admin/paliadin dashboard — global_admin-only. Shows total
turns, last-7-days, median/p90 duration, tool-use rate (the
load-bearing §0.5.7 metric), abandon rate, classifier
histogram, daily sparkline, top prompts, recent turn log.
Powered by PaliadinService.Stats() + ListRecentTurns().
- frontend: paliadin.tsx + client/paliadin.ts (chat panel with
starter prompts, EventSource consumer, typewriter render of
one-shot content blob, citation-chip parser, "Stop" + "New
conversation" buttons, localStorage history); admin-paliadin
pair (read-only stats dashboard).
- Sidebar: Paliadin entry under Übersicht (ICON_SPARKLE);
Paliadin Monitor under Admin.
- 36 i18n keys (DE+EN), CSS for chat panel + dashboard.
- main.go: PaliadinService wires only on PALIADIN_ENABLED=true,
with PALIADIN_TMUX_SESSION + PALIADIN_RESPONSE_DIR overrides.
Logs visibly so the operator can confirm at boot.
- CLAUDE.md: ANTHROPIC_API_KEY row updated (PoC doesn't need it
— Claude CLI uses m's subscription; key reserved for future
production-v1). New rows for the three PALIADIN_* env vars.
Tests
-----
- 7 unit tests on the trailer parser, chip counter, token approx,
and tmux-input sanitiser. All pass. The trailer parser is
load-bearing for monitoring; an unobserved parser bug = silent
dashboard rot.
What's NOT in v1 (stays deferred)
---------------------------------
- The Anthropic API client (production v1, gated on PoC success
per §0.5.7).
- BYO-AI / OpenAI adapter.
- Per-user rate limiting.
- Multi-replica SSE bus.
- Mascot / avatar SVG.
- Persistent threads (history is browser localStorage only).
How to use locally
------------------
$ export PALIADIN_ENABLED=true
$ ./paliad
# browse /paliadin → type a question → answers stream back
# /admin/paliadin shows the monitoring dashboard
Migration: 058 (skips fritz's t-147 on 057). Safe on prod
because PALIADIN_ENABLED defaults to false; the table is created
but no routes touch it until the env var flips.
This commit is contained in:
3
internal/db/migrations/058_paliadin_poc.down.sql
Normal file
3
internal/db/migrations/058_paliadin_poc.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-146: Paliadin PoC — drop paliad.paliadin_turns.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.paliadin_turns;
|
||||
142
internal/db/migrations/058_paliadin_poc.up.sql
Normal file
142
internal/db/migrations/058_paliadin_poc.up.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
-- t-paliad-146: Paliadin PoC — paliad.paliadin_turns.
|
||||
--
|
||||
-- Design: docs/design-paliadin-2026-05-07.md §0.5.6 (PoC variant).
|
||||
--
|
||||
-- Paliadin is the in-app conversational AI assistant. Phase 0 PoC runs on
|
||||
-- m's laptop only (PALIADIN_ENABLED=false on prod default), backed by a
|
||||
-- long-lived `claude` process inside a tmux session — not the Anthropic
|
||||
-- Messages API. The PoC's load-bearing artefact is monitoring: every
|
||||
-- turn writes a row here so m can decide via /admin/paliadin whether the
|
||||
-- feature earns a production v1 build.
|
||||
--
|
||||
-- The PoC variant of this table stores the FULL prompt + response (no
|
||||
-- redaction) because m is the only user, m is m's own compliance officer,
|
||||
-- and the whole point is to read what was asked later. Production v1
|
||||
-- swaps to hash-only storage; that's a separate migration.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. CREATE paliad.paliadin_turns (with RLS).
|
||||
-- 2. Indexes.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.paliadin_turns
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE paliad.paliadin_turns (
|
||||
turn_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Who asked. FK to paliad.users (not auth.users) so deleting an auth
|
||||
-- row leaves the audit trail intact via paliad.users.
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id),
|
||||
|
||||
-- Browser session ID (opaque). Lets us group turns into "a single
|
||||
-- conversation" without storing the full thread server-side.
|
||||
session_id text NOT NULL,
|
||||
|
||||
started_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz, -- NULL until end-of-turn
|
||||
duration_ms int, -- finished_at - started_at
|
||||
|
||||
-- The user's prompt, verbatim. PoC scope only — production v1 stores
|
||||
-- a redacted hash instead. See docs/design-paliadin-2026-05-07.md §3.3.
|
||||
user_message text NOT NULL,
|
||||
|
||||
-- Claude's response, verbatim, with the [paliadin-meta] trailer
|
||||
-- already stripped. The trailer's parsed fields land in `used_tools`,
|
||||
-- `rows_seen`, `chip_count`, `classifier_tag` below.
|
||||
response text,
|
||||
|
||||
-- Approximate token count (server-side word_count * 1.3). Claude Code
|
||||
-- via tmux doesn't expose Anthropic's usage block, so this is a
|
||||
-- coarse heuristic for the dashboard cost trend — not a billing
|
||||
-- number.
|
||||
response_tokens int,
|
||||
|
||||
-- Tool names Claude used during this turn, parsed from the
|
||||
-- [paliadin-meta] trailer block ("used_tools: search_my_deadlines,
|
||||
-- lookup_court"). Empty array means Claude didn't use any tool —
|
||||
-- the load-bearing dashboard signal: high tool-use rate justifies
|
||||
-- the data-grounding pitch in §8.1.
|
||||
used_tools text[] NOT NULL DEFAULT '{}'::text[],
|
||||
|
||||
-- Row counts parallel to used_tools (e.g. "rows_seen: 3, 1" → {3, 1}).
|
||||
-- Helps spot "tool ran but returned nothing" patterns.
|
||||
rows_seen int[] NOT NULL DEFAULT '{}'::int[],
|
||||
|
||||
-- Number of action chips Claude embedded in the response.
|
||||
chip_count int NOT NULL DEFAULT 0,
|
||||
|
||||
-- True if the user closed the SSE stream before Claude finished.
|
||||
abandoned boolean NOT NULL DEFAULT false,
|
||||
|
||||
-- Which paliad page m was on when he asked. Empty when invoked from
|
||||
-- /paliadin directly.
|
||||
page_origin text,
|
||||
|
||||
-- Error code, NULL on success. Possible values:
|
||||
-- tmux_unresponsive — couldn't write to the pane / pane died
|
||||
-- pane_died — tmux window closed mid-turn
|
||||
-- user_aborted — abandoned=true synonym, kept for query clarity
|
||||
-- timeout — Claude didn't write the response file in time
|
||||
-- prompt_disabled — PALIADIN_ENABLED=false at request time
|
||||
error_code text,
|
||||
|
||||
-- Coarse self-classification by Claude itself in the [paliadin-meta]
|
||||
-- trailer ("data" / "concept" / "navigation" / "meta" / "other").
|
||||
-- Drives the use-case-shape histogram on /admin/paliadin.
|
||||
classifier_tag text
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Indexes
|
||||
-- ============================================================================
|
||||
|
||||
-- Per-user timeline (the "my recent paliadin turns" query). Most rows for
|
||||
-- the PoC will share user_id=m, so this index is mostly useful as a sort
|
||||
-- helper.
|
||||
CREATE INDEX paliadin_turns_user_started_idx
|
||||
ON paliad.paliadin_turns(user_id, started_at DESC);
|
||||
|
||||
-- Global timeline for /admin/paliadin dashboard. Keeps the dashboard
|
||||
-- queries (top-N recent turns, daily counts) on an index scan even as
|
||||
-- the table grows.
|
||||
CREATE INDEX paliadin_turns_started_idx
|
||||
ON paliad.paliadin_turns(started_at DESC);
|
||||
|
||||
-- Histogram queries on classifier_tag. Tiny table at PoC scale; the
|
||||
-- index pays for itself once we have weeks of data.
|
||||
CREATE INDEX paliadin_turns_classifier_idx
|
||||
ON paliad.paliadin_turns(classifier_tag, started_at DESC)
|
||||
WHERE classifier_tag IS NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. RLS
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.paliadin_turns ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- A user sees their own turns; global_admin sees all rows. The /admin/
|
||||
-- paliadin dashboard runs under m (global_admin) and so sees the full
|
||||
-- log. Other users would only see their own — though in PoC scope
|
||||
-- there's only m, the policy is the production-shape from day one.
|
||||
CREATE POLICY paliadin_turns_select
|
||||
ON paliad.paliadin_turns FOR SELECT
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
OR EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
|
||||
-- Service-role (paliad backend) writes. Direct-auth INSERT is blocked.
|
||||
-- Paliad runs with the service role today so the policy is inert in
|
||||
-- practice; we still enable RLS so future direct-auth callers are gated.
|
||||
CREATE POLICY paliadin_turns_insert_admin_only
|
||||
ON paliad.paliadin_turns FOR INSERT
|
||||
WITH CHECK (false);
|
||||
|
||||
CREATE POLICY paliadin_turns_update_admin_only
|
||||
ON paliad.paliadin_turns FOR UPDATE
|
||||
USING (false);
|
||||
|
||||
COMMENT ON TABLE paliad.paliadin_turns IS
|
||||
'Per-turn audit log for Paliadin (in-app AI). PoC variant stores full prompt + response — production v1 will swap to hash-only. Powers /admin/paliadin dashboard. Design: docs/design-paliadin-2026-05-07.md §0.5.6.';
|
||||
Reference in New Issue
Block a user